From f91e824150ffb629ae65e50b13311f97f1dbdeca Mon Sep 17 00:00:00 2001 From: Andrew Reed Date: Thu, 29 Jun 2017 13:51:24 -0700 Subject: [PATCH 1/2] CLI using cobra Subcommands are implemented as methods on runners type, which holds dependencies. Integration tests with ginkgo run against an API. --- Dockerfile | 4 ++ Makefile | 63 ++++++++++++-------- README.md | 9 ++- cli/cmd/channel.go | 16 +++++ cli/cmd/channel_create.go | 34 +++++++++++ cli/cmd/channel_inspect.go | 59 +++++++++++++++++++ cli/cmd/channel_ls.go | 26 +++++++++ cli/cmd/channel_rm.go | 35 +++++++++++ cli/cmd/cmd_test.go | 20 +++++++ cli/cmd/release.go | 16 +++++ cli/cmd/release_create.go | 45 ++++++++++++++ cli/cmd/release_inspect.go | 44 ++++++++++++++ cli/cmd/release_ls.go | 28 +++++++++ cli/cmd/release_promote.go | 51 ++++++++++++++++ cli/cmd/release_update.go | 45 ++++++++++++++ cli/cmd/root.go | 86 +++++++++++++++++++++++++++ cli/cmd/runner.go | 15 +++++ cli/main.go | 15 +++++ cli/print/channel_adoption.go | 78 +++++++++++++++++++++++++ cli/print/channel_attributes.go | 22 +++++++ cli/print/channel_license_counts.go | 59 +++++++++++++++++++ cli/print/channel_releases.go | 31 ++++++++++ cli/print/channels.go | 22 +++++++ cli/print/print.go | 2 + cli/print/release.go | 24 ++++++++ cli/print/releases.go | 48 +++++++++++++++ cli/test/channel_create_test.go | 59 +++++++++++++++++++ cli/test/channel_inspect_test.go | 90 ++++++++++++++++++++++++++++ cli/test/channel_list_test.go | 61 +++++++++++++++++++ cli/test/channel_rm_test.go | 61 +++++++++++++++++++ cli/test/cli_test.go | 11 ++++ cli/test/release_create_test.go | 91 +++++++++++++++++++++++++++++ cli/test/release_inspect.go | 68 +++++++++++++++++++++ cli/test/release_ls_test.go | 56 ++++++++++++++++++ cli/test/release_promote_test.go | 64 ++++++++++++++++++++ cli/test/release_update_test.go | 55 +++++++++++++++++ cli/test/util.go | 24 ++++++++ cli/yaml.yaml | 44 ++++++++++++++ client/app.go | 58 ++++++++++++++++++ client/channel.go | 11 ++++ client/channel_test.go | 60 ------------------- client/client.go | 18 +++++- client/client_test.go | 8 --- client/errors.go | 32 ++++++++++ client/release.go | 15 +++-- client/release_test.go | 34 ----------- glide.lock | 20 ++++--- glide.yaml | 12 +++- 48 files changed, 1699 insertions(+), 150 deletions(-) create mode 100644 cli/cmd/channel.go create mode 100644 cli/cmd/channel_create.go create mode 100644 cli/cmd/channel_inspect.go create mode 100644 cli/cmd/channel_ls.go create mode 100644 cli/cmd/channel_rm.go create mode 100644 cli/cmd/cmd_test.go create mode 100644 cli/cmd/release.go create mode 100644 cli/cmd/release_create.go create mode 100644 cli/cmd/release_inspect.go create mode 100644 cli/cmd/release_ls.go create mode 100644 cli/cmd/release_promote.go create mode 100644 cli/cmd/release_update.go create mode 100644 cli/cmd/root.go create mode 100644 cli/cmd/runner.go create mode 100644 cli/main.go create mode 100644 cli/print/channel_adoption.go create mode 100644 cli/print/channel_attributes.go create mode 100644 cli/print/channel_license_counts.go create mode 100644 cli/print/channel_releases.go create mode 100644 cli/print/channels.go create mode 100644 cli/print/print.go create mode 100644 cli/print/release.go create mode 100644 cli/print/releases.go create mode 100644 cli/test/channel_create_test.go create mode 100644 cli/test/channel_inspect_test.go create mode 100644 cli/test/channel_list_test.go create mode 100644 cli/test/channel_rm_test.go create mode 100644 cli/test/cli_test.go create mode 100644 cli/test/release_create_test.go create mode 100644 cli/test/release_inspect.go create mode 100644 cli/test/release_ls_test.go create mode 100644 cli/test/release_promote_test.go create mode 100644 cli/test/release_update_test.go create mode 100644 cli/test/util.go create mode 100644 cli/yaml.yaml create mode 100644 client/app.go delete mode 100644 client/channel_test.go delete mode 100644 client/client_test.go create mode 100644 client/errors.go delete mode 100644 client/release_test.go diff --git a/Dockerfile b/Dockerfile index fbfaf06c2..6e6a29976 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,10 @@ ENV PROJECTPATH=/go/src/github.com/replicatedhq/replicated RUN go get golang.org/x/tools/cmd/goimports +RUN go get github.com/spf13/cobra/cobra + +RUN go get github.com/go-swagger/go-swagger/cmd/swagger + WORKDIR $PROJECTPATH CMD ["/bin/bash"] diff --git a/Makefile b/Makefile index 3abfcfacc..eb42337bb 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +API_PKGS=apps channels releases + docker: docker build -t replicatedhq.replicated . @@ -7,7 +9,7 @@ shell: replicatedhq.replicated clean: - rm -rf gen + sudo rm -rf gen/go deps: docker run --rm \ @@ -15,30 +17,41 @@ deps: replicatedhq.replicated glide install test: - go test ./client + go test ./cli/test -gen: - docker run --rm \ - --volume `pwd`:/local \ - swaggerapi/swagger-codegen-cli generate \ - -Dmodels -DmodelsDocs=false \ - -i https://api.replicated.com/vendor/v1/spec/channels.json \ - -l go \ - -o /local/gen/go/channels - docker run --rm \ - --volume `pwd`:/local \ - swaggerapi/swagger-codegen-cli generate \ - -Dmodels -DmodelsDocs=false \ - -i https://api.replicated.com/vendor/v1/spec/releases.json \ - -l go \ - -o /local/gen/go/releases - sudo chown -R ${USER}:${USER} gen/ - # fix time.Time fields. Codegen generates empty Time struct. - rm gen/go/releases/time.go - sed -i 's/Time/time.Time/' gen/go/releases/app_release_info.go - # import "time" +# fetch the swagger specs from the production Vendor API +get-spec-prod: + mkdir -p gen/spec/ + for PKG in ${API_PKGS}; do \ + curl -o gen/spec/$$PKG.json \ + https://api.replicated.com/vendor/v1/spec/$$PKG.json; \ + done + +# generate the swagger specs from the local replicatedcom/vendor-api repo +get-spec-local: + mkdir -p gen/spec/ docker run --rm \ - --volume `pwd`:/go/src/github.com/replicatedhq/replicated \ - replicatedhq.replicated goimports -w gen/go/releases + --volume ${GOPATH}/src/github.com:/go/src/github.com \ + replicatedhq.replicated /bin/bash -c ' \ + for PKG in ${API_PKGS}; do \ + swagger generate spec \ + -b ../../replicatedcom/vendor-api/handlers/replv1/$$PKG \ + -o gen/spec/$$PKG.json; \ + done' + +gen-models: + for PKG in ${API_PKGS}; do \ + docker run --rm \ + --volume `pwd`:/local \ + swaggerapi/swagger-codegen-cli generate \ + -Dmodels -DmodelsDocs=false \ + -i /local/gen/spec/$$PKG.json \ + -l go \ + -o /local/gen/go/$$PKG; \ + done + +build: deps gen-models -build: deps gen +install: + go build -o replicated cli/main.go + mv replicated ${GOPATH}/bin diff --git a/README.md b/README.md index cf8711011..984ad8329 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ This repository provides a client and CLI for interacting with the Replicated Vendor API. The models are generated from the API's swagger spec. -## Tests -Set the following env vars to run integration tests against the Vendor API. - * VENDOR_API_KEY - * VENDOR_API_ORIGIN - * VENDOR_APP_ID +Set the following env vars to avoid passing them as arguments to each command. + * REPLICATED_APP_SLUG + * REPLICATED_API_TOKEN +```REPLICATED_API_ORIGIN``` may also be set for testing. diff --git a/cli/cmd/channel.go b/cli/cmd/channel.go new file mode 100644 index 000000000..cb6443067 --- /dev/null +++ b/cli/cmd/channel.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// channelCmd represents the channel command +var channelCmd = &cobra.Command{ + Use: "channel", + Short: "Manage and review channels", + Long: "Manage and review channels", +} + +func init() { + RootCmd.AddCommand(channelCmd) +} diff --git a/cli/cmd/channel_create.go b/cli/cmd/channel_create.go new file mode 100644 index 000000000..c340c7e68 --- /dev/null +++ b/cli/cmd/channel_create.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "github.com/replicatedhq/replicated/cli/print" + "github.com/spf13/cobra" +) + +var channelCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new channel in your app", + Long: `Create a new channel in your app and print the full set of channels in the app on success. + +Example: +replicated channel create --name Beta --description 'New features subject to change'`, +} + +var channelCreateName string +var channelCreateDescription string + +func init() { + channelCmd.AddCommand(channelCreateCmd) + + channelCreateCmd.Flags().StringVar(&channelCreateName, "name", "", "The name of this channel") + channelCreateCmd.Flags().StringVar(&channelCreateDescription, "description", "", "A longer description of this channel") +} + +func (r *runners) channelCreate(cmd *cobra.Command, args []string) error { + allChannels, err := r.api.CreateChannel(r.appID, channelCreateName, channelCreateDescription) + if err != nil { + return err + } + + return print.Channels(r.w, allChannels) +} diff --git a/cli/cmd/channel_inspect.go b/cli/cmd/channel_inspect.go new file mode 100644 index 000000000..0e8768825 --- /dev/null +++ b/cli/cmd/channel_inspect.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/replicatedhq/replicated/cli/print" +) + +// channelInspectCmd represents the channelInspect command +var channelInspectCmd = &cobra.Command{ + Use: "inspect", + Short: "Show full details for a channel", + Long: `replicated channel inspect be52315888f23408e2e4dc9242d4cc2c`, +} + +func init() { + channelCmd.AddCommand(channelInspectCmd) +} + +func (r *runners) channelInspect(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf(cmd.UsageString()) + } + chanID := args[0] + + appChan, releases, err := r.api.GetChannel(r.appID, chanID) + if err != nil { + return err + } + + if err = print.ChannelAttrs(r.w, appChan); err != nil { + return err + } + + if _, err = fmt.Fprint(r.w, "\nADOPTION\n"); err != nil { + return err + } + if err = print.ChannelAdoption(r.w, &appChan.Adoption); err != nil { + return err + } + + if _, err = fmt.Fprint(r.w, "\nLICENSE_COUNTS\n"); err != nil { + return err + } + if err = print.LicenseCounts(r.w, &appChan.LicenseCounts); err != nil { + return err + } + + if _, err = fmt.Fprint(r.w, "\nRELEASES\n"); err != nil { + return err + } + if err = print.ChannelReleases(r.w, releases); err != nil { + return err + } + + return nil +} diff --git a/cli/cmd/channel_ls.go b/cli/cmd/channel_ls.go new file mode 100644 index 000000000..350eb74d1 --- /dev/null +++ b/cli/cmd/channel_ls.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/replicatedhq/replicated/cli/print" + "github.com/spf13/cobra" +) + +// channelLsCmd represents the channelLs command +var channelLsCmd = &cobra.Command{ + Use: "ls", + Short: "List all channels in your app", + Long: "List all channels in your app", +} + +func init() { + channelCmd.AddCommand(channelLsCmd) +} + +func (r *runners) channelList(cmd *cobra.Command, args []string) error { + channels, err := r.api.ListChannels(r.appID) + if err != nil { + return err + } + + return print.Channels(r.w, channels) +} diff --git a/cli/cmd/channel_rm.go b/cli/cmd/channel_rm.go new file mode 100644 index 000000000..df02d2939 --- /dev/null +++ b/cli/cmd/channel_rm.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// channelRmCmd represents the channelRm command +var channelRmCmd = &cobra.Command{ + Use: "rm ", + Short: "Remove (archive) a channel", + Long: `replicated channel rm 4d3d240ea1ec4dab0be3b2105ff4b4ed`, +} + +func init() { + channelCmd.AddCommand(channelRmCmd) +} + +func (r *runners) channelRemove(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("channel ID is required") + } + chanID := args[0] + + if err := r.api.ArchiveChannel(r.appID, chanID); err != nil { + return err + } + + // ignore the error since operation was successful + fmt.Fprintf(r.w, "Channel %s successfully archived\n", chanID) + r.w.Flush() + + return nil +} diff --git a/cli/cmd/cmd_test.go b/cli/cmd/cmd_test.go new file mode 100644 index 000000000..c68fe44a0 --- /dev/null +++ b/cli/cmd/cmd_test.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "crypto/rand" + "encoding/base64" + "io" + "testing" +) + +// generate a random string or call t.Fatal +func token(t *testing.T, n int) string { + if n == 0 { + n = 256 + } + data := make([]byte, int(n)) + if _, err := io.ReadFull(rand.Reader, data); err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(data) +} diff --git a/cli/cmd/release.go b/cli/cmd/release.go new file mode 100644 index 000000000..a2508433d --- /dev/null +++ b/cli/cmd/release.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// releaseCmd represents the release command +var releaseCmd = &cobra.Command{ + Use: "release", + Short: "manage app releases", + Long: `The release command allows vendors to create, display, modify, and archive their releases.`, +} + +func init() { + RootCmd.AddCommand(releaseCmd) +} diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go new file mode 100644 index 000000000..7e6583766 --- /dev/null +++ b/cli/cmd/release_create.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var createReleaseYaml string + +var releaseCreateCmd = &cobra.Command{ + Use: "create", + Short: "create a new release", + Long: `Provide YAML configuration for the next release in your sequence.`, +} + +func init() { + releaseCmd.AddCommand(releaseCreateCmd) + + releaseCreateCmd.Flags().StringVar(&createReleaseYaml, "yaml", "", "The YAML config for this release") +} + +func (r *runners) releaseCreate(cmd *cobra.Command, args []string) error { + // TODO can cobra do this? + if createReleaseYaml == "" { + return fmt.Errorf("yaml is required") + } + + // API does not accept yaml in create operation, so first create then udpate + release, err := r.api.CreateRelease(r.appID) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(r.w, "SEQUENCE: %d\n", release.Sequence); err != nil { + return err + } + r.w.Flush() + + if err := r.api.UpdateRelease(r.appID, release.Sequence, createReleaseYaml); err != nil { + return fmt.Errorf("Failure setting yaml config for release: %v", err) + } + + return nil +} diff --git a/cli/cmd/release_inspect.go b/cli/cmd/release_inspect.go new file mode 100644 index 000000000..0f3a207d8 --- /dev/null +++ b/cli/cmd/release_inspect.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "errors" + "fmt" + "strconv" + + "github.com/replicatedhq/replicated/cli/print" + vendorAPI "github.com/replicatedhq/replicated/client" + "github.com/spf13/cobra" +) + +// releaseInspectCmd represents the inspect command +var releaseInspectCmd = &cobra.Command{ + Use: "inspect", + Short: "replicated release inspect ", + Long: `Print the YAML config for a release +replicated release inspect 123 + `, +} + +func init() { + releaseCmd.AddCommand(releaseInspectCmd) +} + +func (r *runners) releaseInspect(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("release sequence is required") + } + seq, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("Failed to parse sequence argument %s", args[0]) + } + + release, err := r.api.GetRelease(r.appID, seq) + if err != nil { + if err == vendorAPI.ErrNotFound { + return fmt.Errorf("No such release %d", seq) + } + return err + } + + return print.Release(r.w, release) +} diff --git a/cli/cmd/release_ls.go b/cli/cmd/release_ls.go new file mode 100644 index 000000000..0ad2e0937 --- /dev/null +++ b/cli/cmd/release_ls.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "github.com/replicatedhq/replicated/cli/print" + "github.com/spf13/cobra" +) + +// lsCmd represents the ls command +var releaseLsCmd = &cobra.Command{ + Use: "ls", + Short: "list all of an app's releases", + Long: `List all of an app's releases + replicatedReleaseLs +`, +} + +func init() { + releaseCmd.AddCommand(releaseLsCmd) +} + +func (r *runners) releaseList(cmd *cobra.Command, args []string) error { + releases, err := r.api.ListReleases(r.appID) + if err != nil { + return err + } + + return print.Releases(r.w, releases) +} diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go new file mode 100644 index 000000000..93e4ca185 --- /dev/null +++ b/cli/cmd/release_promote.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" +) + +var releaseOptional bool +var releaseNotes string +var releaseVersion string + +// releasePromoteCmd represents the releasePromote command +var releasePromoteCmd = &cobra.Command{ + Use: "promote ", + Short: "Set the release for a channel", + Long: `replicated release promote `, +} + +func init() { + releaseCmd.AddCommand(releasePromoteCmd) + + releasePromoteCmd.Flags().StringVar(&releaseNotes, "release-notes", "", "The **markdown** release notes") + releasePromoteCmd.Flags().BoolVar(&releaseOptional, "optional", false, "If set, this release can be skipped") + releasePromoteCmd.Flags().StringVar(&releaseVersion, "version", "", "A version label for the release in this channel") +} + +func (r *runners) releasePromote(cmd *cobra.Command, args []string) error { + // parse sequence and channel ID positional arguments + if len(args) != 2 { + cmd.Usage() + os.Exit(1) + } + seq, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("Failed to parse sequence argument %s", args[0]) + } + chanID := args[1] + + if err := r.api.PromoteRelease(r.appID, seq, releaseVersion, releaseNotes, !releaseOptional, chanID); err != nil { + return err + } + + // ignore error since operation was successful + fmt.Fprintf(r.w, "Channel %s successfully set to release %d\n", chanID, seq) + r.w.Flush() + + return nil +} diff --git a/cli/cmd/release_update.go b/cli/cmd/release_update.go new file mode 100644 index 000000000..fffaf5709 --- /dev/null +++ b/cli/cmd/release_update.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +var updateReleaseYaml string + +var releaseUpdateCmd = &cobra.Command{ + Use: "update SEQUENCE", + Short: "Updated a release's yaml config", + Long: "Updated a release's yaml config", +} + +func init() { + releaseCmd.AddCommand(releaseUpdateCmd) + + releaseUpdateCmd.Flags().StringVar(&updateReleaseYaml, "yaml", "", "The new YAML config for this release") +} + +func (r *runners) releaseUpdate(cmd *cobra.Command, args []string) error { + if updateReleaseYaml == "" { + return fmt.Errorf("yaml is required") + } + if len(args) < 1 { + return fmt.Errorf("release sequence is required") + } + seq, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid release sequence: %s", args[0]) + } + + if err := r.api.UpdateRelease(r.appID, seq, updateReleaseYaml); err != nil { + return fmt.Errorf("Failure setting new yaml config for release: %v", err) + } + + // ignore the error since operation was successful + fmt.Fprintf(r.w, "Release %d updated\n", seq) + r.w.Flush() + + return nil +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 000000000..551573389 --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "errors" + "io" + "os" + "text/tabwriter" + + "github.com/replicatedhq/replicated/client" + "github.com/spf13/cobra" +) + +// table output settings +const ( + minWidth = 0 + tabWidth = 8 + padding = 4 + padChar = ' ' +) + +var appSlug string +var apiToken string +var apiOrigin = "https://api.replicated.com" + +func init() { + RootCmd.PersistentFlags().StringVar(&appSlug, "app", "", "The app slug to use in all calls") + RootCmd.PersistentFlags().StringVar(&apiToken, "token", "", "The API token to use to access your app in the Vendor API") + + originFromEnv := os.Getenv("REPLICATED_API_ORIGIN") + if originFromEnv != "" { + apiOrigin = originFromEnv + } +} + +// RootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: "replicated", + Short: "Manage channels and releases", + Long: `The replicated CLI allows vendors to manage their apps' channels and releases.`, +} + +// 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(w io.Writer) error { + // get api client and app ID after flags are parsed + runCmds := &runners{w: tabwriter.NewWriter(w, minWidth, tabWidth, padding, padChar, tabwriter.TabIndent)} + + channelCreateCmd.RunE = runCmds.channelCreate + channelInspectCmd.RunE = runCmds.channelInspect + channelLsCmd.RunE = runCmds.channelList + channelRmCmd.RunE = runCmds.channelRemove + releaseCreateCmd.RunE = runCmds.releaseCreate + releaseInspectCmd.RunE = runCmds.releaseInspect + releaseLsCmd.RunE = runCmds.releaseList + releaseUpdateCmd.RunE = runCmds.releaseUpdate + releasePromoteCmd.RunE = runCmds.releasePromote + + RootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if apiToken == "" { + apiToken = os.Getenv("REPLICATED_API_TOKEN") + if apiToken == "" { + return errors.New("Please provide your API token") + } + } + api := client.New(apiOrigin, apiToken) + runCmds.api = api + + if appSlug == "" { + appSlug = os.Getenv("REPLICATED_APP_SLUG") + if appSlug == "" { + return errors.New("Please provide your app slug") + } + } + + // resolve app ID from slug + app, err := api.GetAppBySlug(appSlug) + if err != nil { + return err + } + runCmds.appID = app.Id + + return nil + } + + return RootCmd.Execute() +} diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go new file mode 100644 index 000000000..ebf3128a4 --- /dev/null +++ b/cli/cmd/runner.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "text/tabwriter" + + "github.com/replicatedhq/replicated/client" +) + +// Runner holds the I/O dependencies and configurations used by individual +// commands, which are defined as methods on this type. +type runners struct { + api client.Client + w *tabwriter.Writer + appID string +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 000000000..9785c5737 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/replicatedhq/replicated/cli/cmd" +) + +func main() { + if err := cmd.Execute(os.Stdout); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cli/print/channel_adoption.go b/cli/print/channel_adoption.go new file mode 100644 index 000000000..f3c90753b --- /dev/null +++ b/cli/print/channel_adoption.go @@ -0,0 +1,78 @@ +package print + +import ( + "fmt" + "text/tabwriter" + "text/template" + + channels "github.com/replicatedhq/replicated/gen/go/channels" +) + +var channelAdoptionTmplSrc = ` +LICENSE_TYPE CURRENT PREVIOUS OTHER + ACTIVE/ALL ACTIVE/ALL ACTIVE/ALL +{{ range $licenseType, $counts := . -}} +{{ $licenseType }} {{ $counts.Current.Active }}/{{ $counts.Current.All }} {{ $counts.Previous.Active }}/{{ $counts.Previous.All }} {{ $counts.Other.Active }}/{{ $counts.Other.All }} +{{ end }} +` +var channelAdoptionTmpl = template.Must(template.New("ChannelAdoption").Parse(channelAdoptionTmplSrc)) + +type allActiveCounts struct { + All int64 + Active int64 +} + +type licenseAdoption struct { + Current allActiveCounts + Previous allActiveCounts + Other allActiveCounts +} + +func ChannelAdoption(w *tabwriter.Writer, adoption *channels.ChannelAdoption) error { + countsByLicense := make(map[string]licenseAdoption) + + var getOrSetLicenseAdoption = func(licenseType string) *licenseAdoption { + licenseAdoption, ok := countsByLicense[licenseType] + if !ok { + countsByLicense[licenseType] = licenseAdoption + } + return &licenseAdoption + } + + // current + for licenseType, count := range adoption.CurrentVersionCountActive { + getOrSetLicenseAdoption(licenseType).Current.Active = count + } + for licenseType, count := range adoption.CurrentVersionCountAll { + getOrSetLicenseAdoption(licenseType).Current.All = count + } + + // previous + for licenseType, count := range adoption.PreviousVersionCountActive { + getOrSetLicenseAdoption(licenseType).Previous.Active = count + } + for licenseType, count := range adoption.PreviousVersionCountAll { + getOrSetLicenseAdoption(licenseType).Previous.All = count + } + + // other + for licenseType, count := range adoption.OtherVersionCountActive { + getOrSetLicenseAdoption(licenseType).Other.Active = count + } + for licenseType, count := range adoption.OtherVersionCountAll { + getOrSetLicenseAdoption(licenseType).Other.All = count + } + + if len(countsByLicense) == 0 { + if _, err := fmt.Fprintln(w, "No licenses in channel"); err != nil { + return err + } + return w.Flush() + } + + if err := channelAdoptionTmpl.Execute(w, countsByLicense); err != nil { + return err + } + + return w.Flush() +} diff --git a/cli/print/channel_attributes.go b/cli/print/channel_attributes.go new file mode 100644 index 000000000..b7e8e9db6 --- /dev/null +++ b/cli/print/channel_attributes.go @@ -0,0 +1,22 @@ +package print + +import ( + "text/tabwriter" + "text/template" + + channels "github.com/replicatedhq/replicated/gen/go/channels" +) + +var channelAttrsTmplSrc = `ID: {{ .Id }} +NAME: {{ .Name }} +DESCRIPTION: {{ .Description }} +` + +var channelAttrsTmpl = template.Must(template.New("ChannelAttributes").Parse(channelAttrsTmplSrc)) + +func ChannelAttrs(w *tabwriter.Writer, appChan *channels.AppChannel) error { + if err := channelAttrsTmpl.Execute(w, appChan); err != nil { + return err + } + return w.Flush() +} diff --git a/cli/print/channel_license_counts.go b/cli/print/channel_license_counts.go new file mode 100644 index 000000000..bbb3addb1 --- /dev/null +++ b/cli/print/channel_license_counts.go @@ -0,0 +1,59 @@ +package print + +import ( + "fmt" + "text/tabwriter" + "text/template" + + channels "github.com/replicatedhq/replicated/gen/go/channels" +) + +var channelLicenseCountsTmplSrc = ` +LICENSE_TYPE ACTIVE AIRGAP INACTIVE TOTAL +{{ range $licenseType, $counts := . -}} +{{ $licenseType }} {{ $counts.Active }} {{ $counts.Airgap }} {{ $counts.Inactive }} {{ $counts.Total }} +{{ end }}` + +var channelLicenseCountsTmpl = template.Must(template.New("ChannelLicenseCounts").Parse(channelLicenseCountsTmplSrc)) + +type licenseTypeCounts struct { + Active, Airgap, Inactive, Total int64 +} + +func LicenseCounts(w *tabwriter.Writer, counts *channels.LicenseCounts) error { + countsByLicenseType := make(map[string]licenseTypeCounts) + + var getOrSetLicenseCounts = func(licenseType string) *licenseTypeCounts { + licenseCounts, ok := countsByLicenseType[licenseType] + if !ok { + countsByLicenseType[licenseType] = licenseCounts + } + return &licenseCounts + } + + for licenseType, count := range counts.Active { + getOrSetLicenseCounts(licenseType).Active = count + } + for licenseType, count := range counts.Airgap { + getOrSetLicenseCounts(licenseType).Airgap = count + } + for licenseType, count := range counts.Inactive { + getOrSetLicenseCounts(licenseType).Inactive = count + } + for licenseType, count := range counts.Total { + getOrSetLicenseCounts(licenseType).Total = count + } + + if len(countsByLicenseType) == 0 { + if _, err := fmt.Fprintln(w, "No licenses in channel"); err != nil { + return err + } + return w.Flush() + } + + if err := channelLicenseCountsTmpl.Execute(w, countsByLicenseType); err != nil { + return err + } + + return w.Flush() +} diff --git a/cli/print/channel_releases.go b/cli/print/channel_releases.go new file mode 100644 index 000000000..305c20373 --- /dev/null +++ b/cli/print/channel_releases.go @@ -0,0 +1,31 @@ +package print + +import ( + "fmt" + "html/template" + "text/tabwriter" + + channels "github.com/replicatedhq/replicated/gen/go/channels" +) + +var channelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE RELEASED VERSION REQUIRED AIRGAP_STATUS RELEASE_NOTES +{{ range . -}} +{{ .ChannelSequence }} {{ .ReleaseSequence }} {{ .Created }} {{ .Version }} {{ .Required }} {{ .AirgapBuildStatus}} {{ .ReleaseNotes }} +{{ end }}` + +var channelReleasesTmpl = template.Must(template.New("ChannelReleases").Parse(channelReleasesTmplSrc)) + +func ChannelReleases(w *tabwriter.Writer, releases []channels.ChannelRelease) error { + if len(releases) == 0 { + if _, err := fmt.Fprintln(w, "No releases in channel"); err != nil { + return err + } + return w.Flush() + } + + if err := channelReleasesTmpl.Execute(w, releases); err != nil { + return err + } + + return w.Flush() +} diff --git a/cli/print/channels.go b/cli/print/channels.go new file mode 100644 index 000000000..32c997d40 --- /dev/null +++ b/cli/print/channels.go @@ -0,0 +1,22 @@ +package print + +import ( + "html/template" + "text/tabwriter" + + channels "github.com/replicatedhq/replicated/gen/go/channels" +) + +var channelsTmplSrc = `ID NAME RELEASE VERSION +{{ range . -}} +{{ .Id }} {{ .Name }} {{ if ge .ReleaseSequence 1 }}{{ .ReleaseSequence }}{{else}} {{end}} {{ .ReleaseLabel }} +{{ end }}` + +var channelsTmpl = template.Must(template.New("channels").Parse(channelsTmplSrc)) + +func Channels(w *tabwriter.Writer, channels []channels.AppChannel) error { + if err := channelsTmpl.Execute(w, channels); err != nil { + return err + } + return w.Flush() +} diff --git a/cli/print/print.go b/cli/print/print.go new file mode 100644 index 000000000..4116084d3 --- /dev/null +++ b/cli/print/print.go @@ -0,0 +1,2 @@ +// Package print contains templates for printing Vendor API types. +package print diff --git a/cli/print/release.go b/cli/print/release.go new file mode 100644 index 000000000..c7b4a562a --- /dev/null +++ b/cli/print/release.go @@ -0,0 +1,24 @@ +package print + +import ( + "text/tabwriter" + "text/template" + + releases "github.com/replicatedhq/replicated/gen/go/releases" +) + +var releaseTmplSrc = `SEQUENCE: {{ .Sequence }} +CREATED: {{ .CreatedAt }} +EDITED: {{ .EditedAt }} +CONFIG: +{{ .Config }} +` + +var releaseTmpl = template.Must(template.New("Release").Parse(releaseTmplSrc)) + +func Release(w *tabwriter.Writer, release *releases.AppRelease) error { + if err := releaseTmpl.Execute(w, release); err != nil { + return err + } + return w.Flush() +} diff --git a/cli/print/releases.go b/cli/print/releases.go new file mode 100644 index 000000000..ca0029e0e --- /dev/null +++ b/cli/print/releases.go @@ -0,0 +1,48 @@ +package print + +import ( + "strings" + "text/tabwriter" + "text/template" + + releases "github.com/replicatedhq/replicated/gen/go/releases" +) + +var releasesTmplSrc = `SEQUENCE VERSION CREATED EDITED ACTIVE_CHANNELS +{{ range . -}} +{{ .Sequence }} {{ .Version }} {{ .CreatedAt }} {{ .EditedAt }} {{ .ActiveChannels }} +{{ end }}` + +var releasesTmpl = template.Must(template.New("Releases").Parse(releasesTmplSrc)) + +func Releases(w *tabwriter.Writer, appReleases []releases.AppReleaseInfo) error { + rs := make([]map[string]interface{}, len(appReleases)) + + for i, r := range appReleases { + // join active channel names like "Stable,Unstable" + activeChans := make([]string, len(r.ActiveChannels)) + for j, activeChan := range r.ActiveChannels { + activeChans[j] = activeChan.Name + } + activeChansField := strings.Join(activeChans, ",") + + // don't print edited if it's the same as created + edited := r.EditedAt.String() + if r.CreatedAt.Equal(r.EditedAt) { + edited = "" + } + rs[i] = map[string]interface{}{ + "Sequence": r.Sequence, + "Version": r.Version, + "CreatedAt": r.CreatedAt, + "EditedAt": edited, + "ActiveChannels": activeChansField, + } + } + + if err := releasesTmpl.Execute(w, rs); err != nil { + return err + } + + return w.Flush() +} diff --git a/cli/test/channel_create_test.go b/cli/test/channel_create_test.go new file mode 100644 index 000000000..4a7702938 --- /dev/null +++ b/cli/test/channel_create_test.go @@ -0,0 +1,59 @@ +package test + +import ( + "bufio" + "bytes" + "fmt" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("channel create", func() { + var app = &apps.App{Name: mustToken(8)} + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(GinkgoT(), err) + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + name := mustToken(8) + desc := mustToken(16) + Describe(fmt.Sprintf("--name %s --description %s", name, desc), func() { + It("should print the created channel", func() { + t := GinkgoT() + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.RootCmd.SetArgs([]string{"channel", "create", "--name", name, "--description", desc, "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + err := cmd.Execute(&stdout) + + assert.Nil(t, err) + + assert.Empty(t, stderr.String(), "Expected no stderr output") + assert.NotEmpty(t, stdout.String(), "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^ID\s+NAME\s+RELEASE\s+VERSION$`, r.Text()) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^\w+\s+`+name+`\s+$`, r.Text()) + + // default Stable, Beta, and Unstable channels should be listed too + for r.Scan() { + assert.Regexp(t, `^\w+\s+\w+`, r.Text()) + } + }) + }) +}) diff --git a/cli/test/channel_inspect_test.go b/cli/test/channel_inspect_test.go new file mode 100644 index 000000000..c53807c99 --- /dev/null +++ b/cli/test/channel_inspect_test.go @@ -0,0 +1,90 @@ +package test + +import ( + "bufio" + "bytes" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + channels "github.com/replicatedhq/replicated/gen/go/channels" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("channel inspect", func() { + var app = &apps.App{Name: mustToken(8)} + var appChan = &channels.AppChannel{} + + BeforeEach(func() { + t := GinkgoT() + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + + appChans, err := api.ListChannels(app.Id) + assert.Nil(t, err) + appChan = &appChans[0] + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("with an existing channel ID", func() { + Context("with no licenses and no releases", func() { + It("should print the full channel details", func() { + t := GinkgoT() + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.RootCmd.SetArgs([]string{"channel", "inspect", appChan.Id, "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + + err := cmd.Execute(&stdout) + assert.Nil(t, err) + + assert.Zero(t, stderr, "Expected no stderr output") + assert.NotZero(t, stdout, "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^ID: `+appChan.Id+`$`, r.Text()) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^NAME: `+appChan.Name+`$`, r.Text()) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^DESCRIPTION: `+appChan.Description+`$`, r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "ADOPTION", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "No licenses in channel", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "LICENSE_COUNTS", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "No licenses in channel", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "RELEASES", r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "No releases in channel", r.Text()) + }) + }) + }) +}) diff --git a/cli/test/channel_list_test.go b/cli/test/channel_list_test.go new file mode 100644 index 000000000..6e440c0b4 --- /dev/null +++ b/cli/test/channel_list_test.go @@ -0,0 +1,61 @@ +package test + +import ( + "bufio" + "bytes" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + channels "github.com/replicatedhq/replicated/gen/go/channels" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("channel ls", func() { + t := GinkgoT() + var app = &apps.App{Name: mustToken(8)} + var appChans []channels.AppChannel + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + + appChans, err = api.ListChannels(app.Id) + assert.Nil(t, err) + assert.Len(t, appChans, 3) + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("when an app has three channels without releases", func() { + It("should print all the channels", func() { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.RootCmd.SetArgs([]string{"channel", "ls", "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + err := cmd.Execute(&stdout) + + assert.Nil(t, err) + + assert.Zero(t, stderr, "Expected no stderr output") + assert.NotZero(t, stdout, "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^ID\s+NAME\s+RELEASE\s+VERSION$`, r.Text()) + + for i := 0; i < 3; i++ { + assert.True(t, r.Scan()) + assert.Regexp(t, `^\w+\s+\w+\s+`, r.Text()) + } + + assert.False(t, r.Scan()) + }) + }) +}) diff --git a/cli/test/channel_rm_test.go b/cli/test/channel_rm_test.go new file mode 100644 index 000000000..faeb56934 --- /dev/null +++ b/cli/test/channel_rm_test.go @@ -0,0 +1,61 @@ +package test + +import ( + "bufio" + "bytes" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + channels "github.com/replicatedhq/replicated/gen/go/channels" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("channel rm", func() { + t := GinkgoT() + var app = &apps.App{Name: mustToken(8)} + var appChan *channels.AppChannel + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + + appChans, err := api.ListChannels(app.Id) + assert.Nil(t, err) + appChan = &appChans[0] + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("when the channel ID exists", func() { + It("should remove the channel", func() { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.RootCmd.SetArgs([]string{"channel", "rm", appChan.Id, "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + + err := cmd.Execute(&stdout) + assert.Nil(t, err) + + assert.Zero(t, stderr, "Expected no stderr output") + assert.NotZero(t, stdout, "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Equal(t, "Channel "+appChan.Id+" successfully archived", r.Text()) + + assert.False(t, r.Scan()) + + // verify with the api that it's really gone + appChans, err := api.ListChannels(app.Id) + assert.Nil(t, err) + assert.Len(t, appChans, 2) + }) + }) +}) diff --git a/cli/test/cli_test.go b/cli/test/cli_test.go new file mode 100644 index 000000000..546b4f0ef --- /dev/null +++ b/cli/test/cli_test.go @@ -0,0 +1,11 @@ +package test + +import ( + "testing" + + . "github.com/onsi/ginkgo" +) + +func TestCLI(t *testing.T) { + RunSpecs(t, "CLI Suite") +} diff --git a/cli/test/release_create_test.go b/cli/test/release_create_test.go new file mode 100644 index 000000000..d29147588 --- /dev/null +++ b/cli/test/release_create_test.go @@ -0,0 +1,91 @@ +package test + +import ( + "bufio" + "bytes" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + "github.com/stretchr/testify/assert" +) + +var yaml = `--- +replicated_api_version: 2.9.2 +name: "Test" + +# +# https://www.replicated.com/docs/packaging-an-application/application-properties +# +properties: + app_url: http://{{repl ConfigOption "hostname" }} + console_title: "Test" + +# +# https://www.replicated.com/docs/kb/supporting-your-customers/install-known-versions +# +host_requirements: + replicated_version: ">=2.9.2" + +# +# Settings screen +# https://www.replicated.com/docs/packaging-an-application/config-screen +# +config: +- name: hostname + title: Hostname + description: Ensure this domain name is routable on your network. + items: + - name: hostname + title: Hostname + value: '{{repl ConsoleSetting "tls.hostname"}}' + type: text + test_proc: + display_name: Check DNS + command: resolve_host + +# +# Define how the application containers will be created and started +# https://www.replicated.com/docs/packaging-an-application/components-and-containers +# +components: [] +` + +var _ = Describe("release create", func() { + t := GinkgoT() + var app = &apps.App{Name: mustToken(8)} + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("with valid --yaml in an app with no releases", func() { + It("should create release 1", func() { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.RootCmd.SetArgs([]string{"release", "create", "--yaml", yaml, "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + err := cmd.Execute(&stdout) + + assert.Nil(t, err) + + assert.Empty(t, stderr.String(), "Expected no stderr output") + assert.NotEmpty(t, stdout.String(), "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Equal(t, "SEQUENCE: 1", r.Text()) + + assert.False(t, r.Scan()) + }) + }) +}) diff --git a/cli/test/release_inspect.go b/cli/test/release_inspect.go new file mode 100644 index 000000000..bd028f81f --- /dev/null +++ b/cli/test/release_inspect.go @@ -0,0 +1,68 @@ +package test + +import ( + "bufio" + "bytes" + "strconv" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + releases "github.com/replicatedhq/replicated/gen/go/releases" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("release inspect", func() { + t := GinkgoT() + app := &apps.App{Name: mustToken(8)} + var release *releases.AppReleaseInfo + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + + release, err = api.CreateRelease(app.Id) + assert.Nil(t, err) + err = api.UpdateRelease(app.Id, release.Sequence, yaml) + assert.Nil(t, err) + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("with an existing release sequence", func() { + It("should print full details of the release", func() { + seq := strconv.Itoa(int(release.Sequence)) + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.RootCmd.SetArgs([]string{"release", "inspect", seq, "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + + err := cmd.Execute(&stdout) + assert.Nil(t, err) + + assert.Empty(t, stderr.String(), "Expected no stderr output") + assert.NotEmpty(t, stdout.String(), "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Equal(t, "SEQUENCE: "+seq, r.Text()) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^CREATED: \d+`, r.Text()) + + assert.True(t, r.Scan()) + assert.Regexp(t, `^EDITED: \d+`, r.Text()) + + assert.True(t, r.Scan()) + assert.Equal(t, "CONFIG:", r.Text()) + + // remainder of output is the yaml config for the release + }) + }) +}) diff --git a/cli/test/release_ls_test.go b/cli/test/release_ls_test.go new file mode 100644 index 000000000..b93ab2f2a --- /dev/null +++ b/cli/test/release_ls_test.go @@ -0,0 +1,56 @@ +package test + +import ( + "bufio" + "bytes" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("release ls", func() { + t := GinkgoT() + app := &apps.App{Name: mustToken(8)} + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + + _, err = api.CreateRelease(app.Id) + assert.Nil(t, err) + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("when an app has one release", func() { + It("should print a table of releases with one row", func() { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.RootCmd.SetArgs([]string{"release", "ls", "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + + err := cmd.Execute(&stdout) + assert.Nil(t, err) + + assert.Empty(t, stderr.String(), "Expected no stderr output") + assert.NotEmpty(t, stdout.String(), "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Regexp(t, `SEQUENCE\s+VERSION\s+CREATED\s+EDITED\s+ACTIVE_CHANNELS`, r.Text()) + + assert.True(t, r.Scan()) + assert.Regexp(t, `\d+\s+`, r.Text()) + + assert.False(t, r.Scan()) + }) + }) +}) diff --git a/cli/test/release_promote_test.go b/cli/test/release_promote_test.go new file mode 100644 index 000000000..26e6bfd9b --- /dev/null +++ b/cli/test/release_promote_test.go @@ -0,0 +1,64 @@ +package test + +import ( + "bufio" + "bytes" + "strconv" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + channels "github.com/replicatedhq/replicated/gen/go/channels" + releases "github.com/replicatedhq/replicated/gen/go/releases" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("release promote", func() { + t := GinkgoT() + app := &apps.App{Name: mustToken(8)} + var appChan *channels.AppChannel + var release *releases.AppReleaseInfo + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + + release, err = api.CreateRelease(app.Id) + assert.Nil(t, err) + + appChannels, err := api.ListChannels(app.Id) + assert.Nil(t, err) + appChan = &appChannels[0] + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("when a channel with no releases is promoted to release 1", func() { + It("should succeed", func() { + var stdout bytes.Buffer + var stderr bytes.Buffer + + sequence := strconv.Itoa(int(release.Sequence)) + + cmd.RootCmd.SetArgs([]string{"release", "promote", sequence, appChan.Id, "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + + err := cmd.Execute(&stdout) + assert.Nil(t, err) + + assert.Empty(t, stderr.String(), "Expected no stderr output") + assert.NotEmpty(t, stdout.String(), "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + assert.Equal(t, "Channel "+appChan.Id+" successfully set to release "+sequence, r.Text()) + + assert.False(t, r.Scan()) + }) + }) +}) diff --git a/cli/test/release_update_test.go b/cli/test/release_update_test.go new file mode 100644 index 000000000..6a3cb1363 --- /dev/null +++ b/cli/test/release_update_test.go @@ -0,0 +1,55 @@ +package test + +import ( + "bufio" + "bytes" + "strconv" + + . "github.com/onsi/ginkgo" + "github.com/replicatedhq/replicated/cli/cmd" + apps "github.com/replicatedhq/replicated/gen/go/apps" + releases "github.com/replicatedhq/replicated/gen/go/releases" + "github.com/stretchr/testify/assert" +) + +var _ = Describe("release update", func() { + t := GinkgoT() + app := &apps.App{Name: mustToken(8)} + var release *releases.AppReleaseInfo + + BeforeEach(func() { + var err error + app, err = api.CreateApp(app.Name) + assert.Nil(t, err) + + release, err = api.CreateRelease(app.Id) + assert.Nil(t, err) + }) + + AfterEach(func() { + // ignore error, garbage collection + api.DeleteApp(app.Id) + }) + + Context("with an existing release sequence and valid --yaml", func() { + It("should update the release's config", func() { + var stdout bytes.Buffer + var stderr bytes.Buffer + + sequence := strconv.Itoa(int(release.Sequence)) + + cmd.RootCmd.SetArgs([]string{"release", "update", sequence, "--yaml", yaml, "--app", app.Slug}) + cmd.RootCmd.SetOutput(&stderr) + + err := cmd.Execute(&stdout) + assert.Nil(t, err) + + assert.Empty(t, stderr.String(), "Expected no stderr output") + assert.NotEmpty(t, stdout.String(), "Expected stdout output") + + r := bufio.NewScanner(&stdout) + + assert.True(t, r.Scan()) + }) + }) +}) diff --git a/cli/test/util.go b/cli/test/util.go new file mode 100644 index 000000000..aea4a1989 --- /dev/null +++ b/cli/test/util.go @@ -0,0 +1,24 @@ +package test + +import ( + "crypto/rand" + "encoding/base64" + "io" + "log" + "os" + + "github.com/replicatedhq/replicated/client" +) + +var api = client.New(os.Getenv("REPLICATED_API_ORIGIN"), os.Getenv("REPLICATED_API_TOKEN")) + +func mustToken(n int) string { + if n == 0 { + n = 256 + } + data := make([]byte, int(n)) + if _, err := io.ReadFull(rand.Reader, data); err != nil { + log.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(data) +} diff --git a/cli/yaml.yaml b/cli/yaml.yaml new file mode 100644 index 000000000..4b6d22c40 --- /dev/null +++ b/cli/yaml.yaml @@ -0,0 +1,44 @@ +--- +replicated_api_version: 2.9.2 +name: "Test" + +# +# https://www.replicated.com/docs/packaging-an-application/application-properties +# +properties: + app_url: http://{{repl ConfigOption "hostname" }} + console_title: "Test" + +# +# https://www.replicated.com/docs/kb/supporting-your-customers/install-known-versions +# +host_requirements: + replicated_version: ">=2.9.2" + +# +# Settings screen +# https://www.replicated.com/docs/packaging-an-application/config-screen +# +config: +- name: hostname + title: Hostname + description: Ensure this domain name is routable on your network. + items: + - name: hostname + title: Hostname + value: '{{repl ConsoleSetting "tls.hostname"}}' + type: text + test_proc: + display_name: Check DNS + command: resolve_host + +# +# Define how the application containers will be created and started +# https://www.replicated.com/docs/packaging-an-application/components-and-containers +# +components: [] + +# +# Documentation for additional features +# https://www.replicated.com/docs/packaging-an-application +# diff --git a/client/app.go b/client/app.go new file mode 100644 index 000000000..87e1f43ef --- /dev/null +++ b/client/app.go @@ -0,0 +1,58 @@ +package client + +import ( + "fmt" + "net/http" + + apps "github.com/replicatedhq/replicated/gen/go/apps" +) + +func (c *HTTPClient) ListApps() ([]apps.AppAndChannels, error) { + appsAndChannels := make([]apps.AppAndChannels, 0) + err := c.doJSON("GET", "/v1/apps", http.StatusOK, nil, &appsAndChannels) + if err != nil { + return nil, err + } + return appsAndChannels, nil +} + +func (c *HTTPClient) GetAppBySlug(slug string) (*apps.App, error) { + appsAndChannels, err := c.ListApps() + if err != nil { + return nil, fmt.Errorf("GetAppBySlug: %v", err) + } + for _, ac := range appsAndChannels { + if ac.App.Slug == slug { + return &ac.App, nil + } + } + return nil, ErrNotFound +} + +func (c *HTTPClient) CreateApp(name string) (*apps.App, error) { + reqBody := &apps.Body{Name: name} + app := &apps.App{} + err := c.doJSON("POST", "/v1/app", http.StatusCreated, reqBody, app) + if err != nil { + return nil, err + } + return app, nil +} + +func (c *HTTPClient) DeleteApp(id string) error { + endpoint := fmt.Sprintf("%s/v1/app/%s", c.apiOrigin, id) + req, err := http.NewRequest("DELETE", endpoint, nil) + if err != nil { + return err + } + req.Header.Add("Authorization", c.apiKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("DeleteApp (%s %s): %v", req.Method, endpoint, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("DeleteApp (%s %s): status %d", req.Method, endpoint, resp.StatusCode) + } + return nil +} diff --git a/client/channel.go b/client/channel.go index ca183db27..7b49736b1 100644 --- a/client/channel.go +++ b/client/channel.go @@ -51,3 +51,14 @@ func (c *HTTPClient) ArchiveChannel(appID, channelID string) error { } return nil } + +// GetChannel returns channel details and release history +func (c *HTTPClient) GetChannel(appID, channelID string) (*channels.AppChannel, []channels.ChannelRelease, error) { + path := fmt.Sprintf("/v1/app/%s/channel/%s/releases", appID, channelID) + respBody := channels.InlineResponse2001{} + err := c.doJSON("GET", path, http.StatusOK, nil, &respBody) + if err != nil { + return nil, nil, fmt.Errorf("GetChannel: %v", err) + } + return &respBody.Channel, respBody.Releases, nil +} diff --git a/client/channel_test.go b/client/channel_test.go deleted file mode 100644 index 8ce779ccd..000000000 --- a/client/channel_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package client - -import ( - "testing" -) - -func TestListChannels(t *testing.T) { - client := New(apiOrigin, apiKey) - appChannels, err := client.ListChannels(appID) - if err != nil { - t.Fatal(err) - } - if len(appChannels) == 0 { - t.Error("No channels returned from ListChannels") - } -} - -func TestCreateChannel(t *testing.T) { - client := New(apiOrigin, apiKey) - name := "New Channel" - description := "TestCreateChanel" - appChannels, err := client.CreateChannel(appID, name, description) - if err != nil { - t.Fatal(err) - } - if len(appChannels) == 0 { - t.Error("No channels returned from CreateChannel") - } -} - -func TestArchiveChannel(t *testing.T) { - client := New(apiOrigin, apiKey) - // ensure channel exists to delete - name := "Delete me" - description := "TestDeleteChannel" - appChannels, err := client.CreateChannel(appID, name, description) - if err != nil { - t.Fatal(err) - } - var channelID string - for _, appChannel := range appChannels { - if appChannel.Name == name { - channelID = appChannel.Id - break - } - } - err = client.ArchiveChannel(appID, channelID) - if err != nil { - t.Fatal(err) - } - appChannels, err = client.ListChannels(appID) - if err != nil { - t.Fatal(err) - } - for _, appChannel := range appChannels { - if appChannel.Id == channelID { - t.Errorf("Channel %s not successfully archived", channelID) - } - } -} diff --git a/client/client.go b/client/client.go index bc640be99..9ffc7cad4 100644 --- a/client/client.go +++ b/client/client.go @@ -4,22 +4,31 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" + apps "github.com/replicatedhq/replicated/gen/go/apps" channels "github.com/replicatedhq/replicated/gen/go/channels" releases "github.com/replicatedhq/replicated/gen/go/releases" ) +var ErrNotFound = errors.New("Not found") + type Client interface { + GetAppBySlug(slug string) (*apps.App, error) + CreateApp(name string) (*apps.App, error) + DeleteApp(id string) error + ListChannels(appID string) ([]channels.AppChannel, error) CreateChannel(appID, name, desc string) ([]channels.AppChannel, error) ArchiveChannel(appID, channelID string) error + GetChannel(appID, channelID string) (*channels.AppChannel, []channels.ChannelRelease, error) ListReleases(appID string) ([]releases.AppReleaseInfo, error) CreateRelease(appID string) (*releases.AppReleaseInfo, error) UpdateRelease(appID string, sequence int64, yaml string) error - GetRelease(appID string, sequence int64) (*releases.AppReleaseInfo, error) + GetRelease(appID string, sequence int64) (*releases.AppRelease, error) PromoteRelease( appID string, sequence int64, @@ -50,7 +59,7 @@ func (c *HTTPClient) doJSON(method, path string, successStatus int, reqBody, res var buf bytes.Buffer if reqBody != nil { if err := json.NewEncoder(&buf).Encode(reqBody); err != nil { - return fmt.Errorf("%s %s: %v", method, endpoint, err) + return err } } req, err := http.NewRequest(method, endpoint, &buf) @@ -62,9 +71,12 @@ func (c *HTTPClient) doJSON(method, path string, successStatus int, reqBody, res req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { - return fmt.Errorf("%s %s: %v", method, endpoint, err) + return err } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } if resp.StatusCode != successStatus { return fmt.Errorf("%s %s: status %d", method, endpoint, resp.StatusCode) } diff --git a/client/client_test.go b/client/client_test.go deleted file mode 100644 index 800ba8588..000000000 --- a/client/client_test.go +++ /dev/null @@ -1,8 +0,0 @@ -// These are integration tests that generate garbage in the Vendor API. -package client - -import "os" - -var apiKey = os.Getenv("VENDOR_API_KEY") -var apiOrigin = os.Getenv("VENDOR_API_ORIGIN") -var appID = os.Getenv("VENDOR_APP_ID") diff --git a/client/errors.go b/client/errors.go new file mode 100644 index 000000000..169784616 --- /dev/null +++ b/client/errors.go @@ -0,0 +1,32 @@ +package client + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +type BadRequest struct { + MessageCode string `json="messageCode"` + Message string `json="message"` +} + +func (br *BadRequest) Error() string { + return fmt.Sprintf("%s: %s", br.MessageCode, br.Message) +} + +type badRequestBody struct { + Error *BadRequest +} + +func unmarshalBadRequest(r io.Reader) (*BadRequest, error) { + brb := &badRequestBody{} + if err := json.NewDecoder(r).Decode(brb); err != nil { + return nil, err + } + if brb.Error == nil { + return nil, errors.New("No error in body") + } + return brb.Error, nil +} diff --git a/client/release.go b/client/release.go index afb63b118..530e79fae 100644 --- a/client/release.go +++ b/client/release.go @@ -33,7 +33,7 @@ func (c *HTTPClient) CreateRelease(appID string) (*releases.AppReleaseInfo, erro // UpdateRelease updates a release's yaml. func (c *HTTPClient) UpdateRelease(appID string, sequence int64, yaml string) error { - endpoint := fmt.Sprintf("/v1/app/%s/%d/raw", appID, sequence) + endpoint := fmt.Sprintf("%s/v1/app/%s/%d/raw", c.apiOrigin, appID, sequence) req, err := http.NewRequest("PUT", endpoint, strings.NewReader(yaml)) if err != nil { return err @@ -42,19 +42,22 @@ func (c *HTTPClient) UpdateRelease(appID string, sequence int64, yaml string) er req.Header.Set("Content-Type", "application/yaml") resp, err := http.DefaultClient.Do(req) if err != nil { - return fmt.Errorf("UpdateRelease (%s %s): %v", req.Method, endpoint, err) + return fmt.Errorf("UpdateRelease: %v", err) } - resp.Body.Close() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + if badRequestErr, err := unmarshalBadRequest(resp.Body); err == nil { + return badRequestErr + } return fmt.Errorf("UpdateRelease (%s %s): status %d", req.Method, endpoint, resp.StatusCode) } return nil } // GetRelease returns a release's properties. -func (c *HTTPClient) GetRelease(appID string, sequence int64) (*releases.AppReleaseInfo, error) { - path := fmt.Sprintf("%s/v1/app/%s/release/%d/properties", c.apiOrigin, appID, sequence) - release := &releases.AppReleaseInfo{} +func (c *HTTPClient) GetRelease(appID string, sequence int64) (*releases.AppRelease, error) { + path := fmt.Sprintf("/v1/app/%s/%d/properties", appID, sequence) + release := &releases.AppRelease{} if err := c.doJSON("GET", path, http.StatusOK, nil, release); err != nil { return nil, fmt.Errorf("GetRelease: %v", err) } diff --git a/client/release_test.go b/client/release_test.go deleted file mode 100644 index db28a1e5b..000000000 --- a/client/release_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package client - -import ( - "testing" -) - -func TestListReleases(t *testing.T) { - client := New(apiOrigin, apiKey) - _, err := client.ListReleases(appID) - if err != nil { - t.Fatal(err) - } -} - -func TestCreateRelease(t *testing.T) { - client := New(apiOrigin, apiKey) - _, err := client.CreateRelease(appID) - if err != nil { - t.Fatal(err) - } -} - -func TestPromoteRelease(t *testing.T) { - client := New(apiOrigin, apiKey) - release, err := client.CreateRelease(appID) - if err != nil { - t.Fatal(err) - } - appChannels, err := client.CreateChannel(appID, "name", "Description") - err = client.PromoteRelease(appID, release.Sequence, "v1-labelx", "bug fixx", false, appChannels[0].Id) - if err != nil { - t.Fatal(err) - } -} diff --git a/glide.lock b/glide.lock index 2d13f9cfb..1b6c73f79 100644 --- a/glide.lock +++ b/glide.lock @@ -1,10 +1,16 @@ -hash: 7401793483743807d35d4a921e17dbab2bc293da606fa0b3b7729d9663576ffc -updated: 2017-06-27T17:18:03.865507609-07:00 +hash: 690af7774aac79575dce985b2874f3793eb71300858658c4341cbb6a44144b2e +updated: 2017-07-05T22:42:48.843649956Z imports: -- name: golang.org/x/net - version: 455220fa52c866a8aa14ff5e8cc68cde16b8395e +- name: github.com/onsi/ginkgo + version: 77a8c1e5c40d6bb6c5eb4dd4bdce9763564f6298 +- name: github.com/onsi/gomega + version: 334b8f472b3af5d541c5642701c1e29e2126f486 +- name: github.com/spf13/cobra + version: 8c6fa02d2225de0f9bdcb7ca912556f68d172d8c +- name: github.com/spf13/pflag + version: e57e3eeb33f795204c1ca35f56c44f83227c6e66 +- name: github.com/stretchr/testify + version: 69483b4bd14f5845b5a1e55bca19e954e827f1d0 subpackages: - - publicsuffix -- name: gopkg.in/go-resty/resty.v0 - version: cf81ed0a604d373be63b4c036c6b05c06520615f + - assert testImports: [] diff --git a/glide.yaml b/glide.yaml index 6547cbf7b..a81275ace 100644 --- a/glide.yaml +++ b/glide.yaml @@ -1,4 +1,12 @@ package: github.com/replicatedhq/replicated import: -- package: gopkg.in/go-resty/resty.v0 - version: ^0.13.0 +- package: github.com/spf13/cobra +- package: github.com/spf13/pflag +- package: github.com/onsi/ginkgo + version: ~1.3.1 +- package: github.com/onsi/gomega + version: ~1.1.0 +- package: github.com/stretchr/testify + version: ~1.1.4 + subpackages: + - assert From bbd843387052bdeb2ab3f5c17083ccc9a8f81ccc Mon Sep 17 00:00:00 2001 From: Andrew Reed Date: Thu, 6 Jul 2017 10:39:46 -0700 Subject: [PATCH 2/2] standardize help text --- cli/cmd/channel_inspect.go | 2 +- cli/cmd/channel_rm.go | 4 ++-- cli/cmd/release.go | 2 +- cli/cmd/release_create.go | 6 +++--- cli/cmd/release_inspect.go | 6 ++---- cli/cmd/release_ls.go | 6 ++---- cli/cmd/release_promote.go | 6 ++++-- 7 files changed, 15 insertions(+), 17 deletions(-) diff --git a/cli/cmd/channel_inspect.go b/cli/cmd/channel_inspect.go index 0e8768825..d6c92e5a4 100644 --- a/cli/cmd/channel_inspect.go +++ b/cli/cmd/channel_inspect.go @@ -12,7 +12,7 @@ import ( var channelInspectCmd = &cobra.Command{ Use: "inspect", Short: "Show full details for a channel", - Long: `replicated channel inspect be52315888f23408e2e4dc9242d4cc2c`, + Long: "Show full details for a channel", } func init() { diff --git a/cli/cmd/channel_rm.go b/cli/cmd/channel_rm.go index df02d2939..c8b610e2b 100644 --- a/cli/cmd/channel_rm.go +++ b/cli/cmd/channel_rm.go @@ -8,9 +8,9 @@ import ( // channelRmCmd represents the channelRm command var channelRmCmd = &cobra.Command{ - Use: "rm ", + Use: "rm CHANNEL_ID", Short: "Remove (archive) a channel", - Long: `replicated channel rm 4d3d240ea1ec4dab0be3b2105ff4b4ed`, + Long: "Remove (archive) a channel", } func init() { diff --git a/cli/cmd/release.go b/cli/cmd/release.go index a2508433d..addb25636 100644 --- a/cli/cmd/release.go +++ b/cli/cmd/release.go @@ -7,7 +7,7 @@ import ( // releaseCmd represents the release command var releaseCmd = &cobra.Command{ Use: "release", - Short: "manage app releases", + Short: "Manage app releases", Long: `The release command allows vendors to create, display, modify, and archive their releases.`, } diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index 7e6583766..44482df76 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -10,8 +10,9 @@ var createReleaseYaml string var releaseCreateCmd = &cobra.Command{ Use: "create", - Short: "create a new release", - Long: `Provide YAML configuration for the next release in your sequence.`, + Short: "Create a new release", + Long: `Create a new release by providing YAML configuration for the next release in +your sequence.`, } func init() { @@ -21,7 +22,6 @@ func init() { } func (r *runners) releaseCreate(cmd *cobra.Command, args []string) error { - // TODO can cobra do this? if createReleaseYaml == "" { return fmt.Errorf("yaml is required") } diff --git a/cli/cmd/release_inspect.go b/cli/cmd/release_inspect.go index 0f3a207d8..169311ef4 100644 --- a/cli/cmd/release_inspect.go +++ b/cli/cmd/release_inspect.go @@ -13,10 +13,8 @@ import ( // releaseInspectCmd represents the inspect command var releaseInspectCmd = &cobra.Command{ Use: "inspect", - Short: "replicated release inspect ", - Long: `Print the YAML config for a release -replicated release inspect 123 - `, + Short: "Print the YAML config for a release", + Long: "Print the YAML config for a release", } func init() { diff --git a/cli/cmd/release_ls.go b/cli/cmd/release_ls.go index 0ad2e0937..5372a2956 100644 --- a/cli/cmd/release_ls.go +++ b/cli/cmd/release_ls.go @@ -8,10 +8,8 @@ import ( // lsCmd represents the ls command var releaseLsCmd = &cobra.Command{ Use: "ls", - Short: "list all of an app's releases", - Long: `List all of an app's releases - replicatedReleaseLs -`, + Short: "List all of an app's releases", + Long: "List all of an app's releases", } func init() { diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go index 93e4ca185..720e23bb7 100644 --- a/cli/cmd/release_promote.go +++ b/cli/cmd/release_promote.go @@ -14,9 +14,11 @@ var releaseVersion string // releasePromoteCmd represents the releasePromote command var releasePromoteCmd = &cobra.Command{ - Use: "promote ", + Use: "promote SEQUENCE CHANNEL", Short: "Set the release for a channel", - Long: `replicated release promote `, + Long: `Set the release for a channel + +Example: replicated release promote 15 fe4901690971757689f022f7a460f9b2`, } func init() {