Skip to content

Commit

Permalink
chore: 'octopus version' command, and update readme + code cleanups (#84
Browse files Browse the repository at this point in the history
)
  • Loading branch information
borland authored Sep 21, 2022
1 parent f7290c7 commit f866e09
Show file tree
Hide file tree
Showing 9 changed files with 163 additions and 87 deletions.
91 changes: 65 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,68 @@

---

## Installation

#### Windows - MSI file

Navigate to latest release on the [GitHub releases page](https://github.com/OctopusDeploy/cli/releases) and expand the **Assets** list.

Download and run the file `octopus_[version]_Windows_x86_64.msi`

*Note:* At this time, the installer is x64 only. If you are using Windows on ARM, download the manual archive instead.

#### Windows - Chocolatey

```shell
choco install octopus-cli
```

*Note:* At this time, the chocolatey package is x64 only. If you are using Windows on ARM, download the manual archive instead.

#### macOS - Homebrew

```shell
brew install octopusdeploy/taps/octopus-cli
```

The Homebrew package has native support for macOS Intel and Apple Silicon

#### Linux (debian/ubuntu based distributions)

```shell
sudo apt update && sudo apt install --no-install-recommends gnupg curl ca-certificates apt-transport-https && \
curl -sSfL https://apt.octopus.com/public.key | sudo apt-key add - && \
sudo sh -c "echo deb https://apt.octopus.com/ stable main > /etc/apt/sources.list.d/octopus.com.list" && \
sudo apt update && sudo apt install octopus-cli
```

#### Linux (redhat/fedora based distributions)

```shell
sudo curl -sSfL https://rpm.octopus.com/octopuscli.repo -o /etc/yum.repos.d/octopuscli.repo && \
sudo yum install octopus-cli
```

#### Any Platform - Manual

Download and extract the archive file for your platform from the latest release on the [GitHub releases page](https://github.com/OctopusDeploy/cli/releases).

- macOS (Apple Silicon): `octopus_[version]_macOS_arm64.tar.gz`
- macOS (Intel): `octopus_[version]_macOS_x86_64.tar.gz`
- Windows (x64): `octopus_[version]_Windows_x86_64.zip`
- Linux (x64): `octopus_[version]_Linux_x86_64.tar.gz`

The archive file simply contains a compressed version of the `octopus` binary. If you would like to add it to your `PATH` then you must do this yourself.

#### Any platform - go install
If you have the go development tools installed, you can run

```shell
go install github.com/OctopusDeploy/cli/cmd/octopus@latest
```

This will download the latest public release of the CLI from our GitHub repository, compile it for your platform/architecture, and install the binary in your GOPATH

## Overview

This project aims to create a new CLI (written in Go) for communicating with the Octopus Deploy Server.
Expand Down Expand Up @@ -139,20 +201,7 @@ testutil/ # internal utility code used by both unit and integration tests
integrationtest/ # Contains integration tests
```

## Testing

The most important thing the CLI does is communicate with the Octopus server.

We place high importance on compatibility with the server, and detection of breakages caused by server changes.
As such, Integration tests form the most important part of our testing strategy for the CLI.

If you are writing a new command, or extending an existing one, you should ensure that you have in place integration
tests, which verify against a running instance of the server, that the command behaves correctly.

Unit tests are used to fill gaps that integration testing cannot effectively cover, such as the
workflow of prompting for user input, or highly algorithmic/mathematical code.

### Unit Tests
### Testing

Unit tests for packages follow go language conventions, and is located next to the code it is testing.

Expand Down Expand Up @@ -186,15 +235,6 @@ If your server contains existing data, the tests may fail, and they may modify o

The easiest way to run the tests is to `cd integrationtest` and run `go test ./...` or `gotestsum`

### Architecture to enable Testing of interactive mode

While we aim for most functionality to be covered by integration tests, the question/answer flow when running
in interactive mode is not amenable to integration tests. The test runner process would need a lot of highly complex
and code parsing the CLI's stdout commands and emulating a terminal buffer. This is not a productive use of time.

Rather, we architect the application so that the question/answer flows are contained within simple functions,
that we can test using Unit Tests, supplying a mocked wrapper which impersonates the `Survey` library.

## Guidance and Example of how to create and test new commands

Imagine that the CLI did not contain an "account create" command, and we wished to add one.
Expand Down Expand Up @@ -228,7 +268,6 @@ func NewCmdCreate(f factory.Factory) *cobra.Command {
}
```


#### 2. Create a `Task` which encapsulates the command arguments

in the `executor` package, create a new string constant, and struct to carry the options for your command
Expand Down Expand Up @@ -266,9 +305,9 @@ to do the work (sending data to the octopus server, etc.)

At this point you should have a functioning command which works in automation mode.

#### 4. Write some integration tests to ensure your command works as expected when run against a real server
#### 4. Write unit tests to ensure your command works as expected

Add a new file under the `integrationtest` directory, and write tests as appropriate
The unit tests for [release list](https://github.com/OctopusDeploy/cli/blob/main/pkg/cmd/release/list/list_test.go) are a reasonable place to start with as an example.

#### 5. Implement interactive mode

Expand Down
13 changes: 11 additions & 2 deletions cmd/octopus/main.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package main

import (
_ "embed"
"fmt"
"github.com/AlecAivazis/survey/v2/terminal"
version "github.com/OctopusDeploy/cli"
"github.com/briandowns/spinner"
"github.com/spf13/viper"
"os"
"strings"
"time"

"github.com/AlecAivazis/survey/v2"
Expand Down Expand Up @@ -43,17 +46,23 @@ func main() {
askProvider.DisableInteractive()
}

buildVersion := strings.TrimSpace(version.Version)

clientFactory, err := apiclient.NewClientFactoryFromConfig(askProvider)
if err != nil {
if cmdToRun != "config" {
// a small subset of commands can function even if the app doesn't have valid configuration information
if cmdToRun == "config" || cmdToRun == "version" {
clientFactory = apiclient.NewStubClientFactory()
} else {
// can't possibly work
fmt.Println(err)
os.Exit(3)
}
}

s := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithColor("cyan"))

f := factory.New(clientFactory, askProvider, s)
f := factory.New(clientFactory, askProvider, s, buildVersion)

cmd := root.NewCmdRoot(f, clientFactory, askProvider)

Expand Down
34 changes: 21 additions & 13 deletions pkg/apiclient/client_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,6 @@ type Client struct {
Ask question.AskProvider
}

func requireArguments(items map[string]any) error {
for k, v := range items {
if v == nil {
return fmt.Errorf("required argument %s was nil", k)
}

if vstr, ok := v.(string); ok && vstr == "" {
return fmt.Errorf("required string argument %s was empty", k)
}
}
return nil
}

func NewClientFactory(httpClient *http.Client, host string, apiKey string, spaceNameOrID string, ask question.AskProvider) (ClientFactory, error) {
// httpClient is allowed to be nil; it is passed through to the go-octopusdeploy library which falls back to a default httpClient
if host == "" {
Expand Down Expand Up @@ -286,3 +273,24 @@ func (c *Client) GetSystemClient() (*octopusApiClient.Client, error) {
c.SystemClient = systemClient
return systemClient, nil
}

// NewStubClientFactory returns a stub instance, so you can satisfy external code that needs a ClientFactory
func NewStubClientFactory() ClientFactory {
return &stubClientFactory{}
}

type stubClientFactory struct{}

func (s *stubClientFactory) GetSpacedClient() (*octopusApiClient.Client, error) {
return nil, errors.New("app is not configured correctly")
}

func (s *stubClientFactory) GetSystemClient() (*octopusApiClient.Client, error) {
return nil, errors.New("app is not configured correctly")
}

func (s *stubClientFactory) GetActiveSpace() *spaces.Space { return nil }

func (s *stubClientFactory) SetSpaceNameOrId(_ string) {}

func (s *stubClientFactory) GetHostUrl() string { return "" }
33 changes: 18 additions & 15 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
environmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/environment"
releaseCmd "github.com/OctopusDeploy/cli/pkg/cmd/release"
spaceCmd "github.com/OctopusDeploy/cli/pkg/cmd/space"
"github.com/OctopusDeploy/cli/pkg/cmd/version"
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/OctopusDeploy/cli/pkg/question"
Expand All @@ -24,6 +25,21 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro
Long: `Work seamlessly with Octopus Deploy from the command line.`,
}

// ----- Child Commands -----

cmd.AddCommand(version.NewCmdVersion(f))

// infrastructure
cmd.AddCommand(accountCmd.NewCmdAccount(f))
cmd.AddCommand(environmentCmd.NewCmdEnvironment(f))

// configuration
cmd.AddCommand(configCmd.NewCmdConfig(f))
cmd.AddCommand(spaceCmd.NewCmdSpace(f))
cmd.AddCommand(releaseCmd.NewCmdRelease(f))

// ----- Configuration -----

// commands are expected to print their own errors to avoid double-ups
cmd.SilenceUsage = true
cmd.SilenceErrors = true
Expand All @@ -50,21 +66,8 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro

flagAliases := map[string][]string{constants.FlagOutputFormat: {constants.FlagOutputFormatLegacy}}

// we want to allow outputFormat as well as output-format, but don't advertise it.
// must add this AFTER setting the normalize func or it strips out the flag

// infrastructure commands
cmd.AddCommand(accountCmd.NewCmdAccount(f))
cmd.AddCommand(environmentCmd.NewCmdEnvironment(f))

// configuration commands
cmd.AddCommand(configCmd.NewCmdConfig(f))
cmd.AddCommand(spaceCmd.NewCmdSpace(f))

cmd.AddCommand(releaseCmd.NewCmdRelease(f))

viper.BindPFlag(constants.ConfigNoPrompt, cmdPFlags.Lookup(constants.FlagNoPrompt))
viper.BindPFlag(constants.ConfigSpace, cmdPFlags.Lookup(constants.FlagSpace))
_ = viper.BindPFlag(constants.ConfigNoPrompt, cmdPFlags.Lookup(constants.FlagNoPrompt))
_ = viper.BindPFlag(constants.ConfigSpace, cmdPFlags.Lookup(constants.FlagSpace))
// if we attempt to check the flags before Execute is called, cobra hasn't parsed anything yet,
// so we'll get bad values. PersistentPreRun is a convenient callback for setting up our
// environment after parsing but before execution.
Expand Down
25 changes: 25 additions & 0 deletions pkg/cmd/version/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package version

import (
"fmt"
"github.com/MakeNowJust/heredoc/v2"
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/spf13/cobra"
)

func NewCmdVersion(f factory.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Hidden: true,
Example: fmt.Sprintf(heredoc.Doc(`
$ %s version"
`), constants.ExecutableName),
RunE: func(cmd *cobra.Command, args []string) error {
cmd.Println(f.BuildVersion())
return nil
},
}

return cmd
}
21 changes: 14 additions & 7 deletions pkg/factory/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ type Spinner interface {
}

type factory struct {
client apiclient.ClientFactory
asker question.AskProvider
spinner Spinner
client apiclient.ClientFactory
asker question.AskProvider
spinner Spinner
buildVersion string
}

type Factory interface {
Expand All @@ -28,13 +29,15 @@ type Factory interface {
Spinner() Spinner
IsPromptEnabled() bool
Ask(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error
BuildVersion() string
}

func New(clientFactory apiclient.ClientFactory, asker question.AskProvider, s Spinner) Factory {
func New(clientFactory apiclient.ClientFactory, asker question.AskProvider, s Spinner, buildVersion string) Factory {
return &factory{
client: clientFactory,
asker: asker,
spinner: s,
client: clientFactory,
asker: asker,
spinner: s,
buildVersion: buildVersion,
}
}

Expand Down Expand Up @@ -75,6 +78,10 @@ func (f *factory) Spinner() Spinner {
return f.spinner
}

func (f *factory) BuildVersion() string {
return f.buildVersion
}

// NoSpinner is a static singleton "does nothing" stand-in for spinner if you want to
// call an API that expects a spinner while you're in automation mode.
var NoSpinner Spinner = &noSpinner{}
Expand Down
24 changes: 0 additions & 24 deletions test/fixtures/cobra.go

This file was deleted.

3 changes: 3 additions & 0 deletions test/testutil/fakefactory.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func (f *MockFactory) GetCurrentHost() string {
func (f *MockFactory) Spinner() factory.Spinner {
return f.RawSpinner
}
func (f *MockFactory) BuildVersion() string {
return "0.0.0-test"
}
func (f *MockFactory) IsPromptEnabled() bool {
if f.AskProvider == nil {
return false
Expand Down
6 changes: 6 additions & 0 deletions version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package version

import _ "embed"

//go:embed version.txt
var Version string

0 comments on commit f866e09

Please sign in to comment.