diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 1a3011aa..fd7314d0 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -2,8 +2,6 @@ package main import ( "fmt" - "github.com/OctopusDeploy/cli/pkg/config" - "github.com/spf13/viper" "io" "os" "os/user" @@ -13,6 +11,10 @@ import ( "text/template" "time" + "github.com/OctopusDeploy/cli/pkg/config" + "github.com/OctopusDeploy/cli/pkg/servicemessages" + "github.com/spf13/viper" + "github.com/AlecAivazis/survey/v2" version "github.com/OctopusDeploy/cli" "github.com/OctopusDeploy/cli/pkg/apiclient" @@ -100,7 +102,7 @@ func run(args []string) error { buildVersion := strings.TrimSpace(version.Version) viper := viper.GetViper() c := config.New(viper) - f := factory.New(clientFactory, askProvider, s, buildVersion, c) + f := factory.New(clientFactory, askProvider, s, buildVersion, c, servicemessages.NewProvider(servicemessages.NewOutputPrinter(os.Stdout, os.Stderr))) cmd := root.NewCmdRoot(f, clientFactory, askProvider) cmd.DisableAutoGenTag = true diff --git a/cmd/octopus/main.go b/cmd/octopus/main.go index 26f2dfb7..d336c3a0 100644 --- a/cmd/octopus/main.go +++ b/cmd/octopus/main.go @@ -3,13 +3,15 @@ package main import ( _ "embed" "fmt" - "github.com/OctopusDeploy/cli/pkg/util" "os" "strings" "time" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/AlecAivazis/survey/v2/terminal" version "github.com/OctopusDeploy/cli" + "github.com/OctopusDeploy/cli/pkg/servicemessages" "github.com/briandowns/spinner" "github.com/spf13/viper" @@ -64,13 +66,17 @@ func main() { c := config.New(viper) - f := factory.New(clientFactory, askProvider, s, buildVersion, c) + terminalOut := terminal.NewAnsiStdout(os.Stdout) + terminalErr := terminal.NewAnsiStderr(os.Stderr) + + serviceMessageProvider := servicemessages.NewProvider(servicemessages.NewOutputPrinter(terminalOut, terminalErr)) + f := factory.New(clientFactory, askProvider, s, buildVersion, c, serviceMessageProvider) cmd := root.NewCmdRoot(f, clientFactory, askProvider) // if we don't do this then cmd.Print will get sent to stderr - cmd.SetOut(terminal.NewAnsiStdout(os.Stdout)) - cmd.SetErr(terminal.NewAnsiStderr(os.Stderr)) + cmd.SetOut(terminalOut) + cmd.SetErr(terminalErr) if err := cmd.Execute(); err != nil { cmd.PrintErr(err) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index c363e691..78f01393 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -344,6 +344,7 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error } else { cmd.Printf("Successfully created release version %s\n", releaseVersion) } + f.GetServiceMessageProvider().ServiceMessage("setParameter", map[string]string{"name": "octo.releaseNumber", "value": releaseVersion}) } } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 630a96d7..ea7698b1 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -93,6 +93,9 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro cmdPFlags.BoolP(constants.FlagNoPrompt, "", false, "Disable prompting in interactive mode") + // Enable service messages flag is hidden as it's intended for internal CI/CD use only + cmdPFlags.BoolP(constants.FlagEnableServiceMessages,"", false, "Enable service messages for integration with Octopus CI/CD") + cmdPFlags.MarkHidden(constants.FlagEnableServiceMessages) // Legacy flags brought across from the .NET CLI. // Consumers of these flags will have to explicitly check for them as well as the new // flags. The pflag documentation says you can use SetNormalizeFunc to translate/alias flag @@ -106,6 +109,7 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro _ = viper.BindPFlag(constants.ConfigNoPrompt, cmdPFlags.Lookup(constants.FlagNoPrompt)) _ = viper.BindPFlag(constants.ConfigSpace, cmdPFlags.Lookup(constants.FlagSpace)) + _ = viper.BindPFlag(constants.FlagEnableServiceMessages, cmdPFlags.Lookup(constants.FlagEnableServiceMessages)) // 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. diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 3d7505cf..39b2ccf0 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -6,11 +6,12 @@ const ( // flags for command line switches const ( - FlagHelp = "help" - FlagSpace = "space" - FlagOutputFormat = "output-format" - FlagOutputFormatLegacy = "outputFormat" - FlagNoPrompt = "no-prompt" + FlagHelp = "help" + FlagSpace = "space" + FlagOutputFormat = "output-format" + FlagOutputFormatLegacy = "outputFormat" + FlagNoPrompt = "no-prompt" + FlagEnableServiceMessages = "enable-service-messages" ) // flags for storing things in the go context diff --git a/pkg/factory/factory.go b/pkg/factory/factory.go index 477e4f77..85bda911 100644 --- a/pkg/factory/factory.go +++ b/pkg/factory/factory.go @@ -7,6 +7,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/apiclient" "github.com/OctopusDeploy/cli/pkg/config" "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/servicemessages" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" ) @@ -18,11 +19,12 @@ type Spinner interface { } type factory struct { - client apiclient.ClientFactory - asker question.AskProvider - spinner Spinner - buildVersion string - config config.IConfigProvider + client apiclient.ClientFactory + asker question.AskProvider + spinner Spinner + buildVersion string + config config.IConfigProvider + serviceMessageProvider servicemessages.Provider } type Factory interface { @@ -36,15 +38,22 @@ type Factory interface { BuildVersion() string GetHttpClient() (*http.Client, error) GetConfigProvider() (config.IConfigProvider, error) + GetServiceMessageProvider() servicemessages.Provider } -func New(clientFactory apiclient.ClientFactory, asker question.AskProvider, s Spinner, buildVersion string, config config.IConfigProvider) Factory { +func New(clientFactory apiclient.ClientFactory, + asker question.AskProvider, + s Spinner, + buildVersion string, + config config.IConfigProvider, + serviceMessageProvider servicemessages.Provider) Factory { return &factory{ - client: clientFactory, - asker: asker, - spinner: s, - buildVersion: buildVersion, - config: config, + client: clientFactory, + asker: asker, + spinner: s, + buildVersion: buildVersion, + config: config, + serviceMessageProvider: serviceMessageProvider, } } @@ -97,6 +106,10 @@ func (f *factory) GetConfigProvider() (config.IConfigProvider, error) { return f.config, nil } +func (f *factory) GetServiceMessageProvider() servicemessages.Provider { + return f.serviceMessageProvider +} + // 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{} diff --git a/pkg/servicemessages/provider.go b/pkg/servicemessages/provider.go new file mode 100644 index 00000000..d42cc116 --- /dev/null +++ b/pkg/servicemessages/provider.go @@ -0,0 +1,78 @@ +package servicemessages + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/spf13/viper" +) + +type Provider interface { + ServiceMessage(messageName string, values any) +} + +type provider struct { + printer *OutputPrinter +} + +func NewProvider(printer *OutputPrinter) Provider { + return &provider{ + printer: printer, + } +} + +func (p *provider) ServiceMessage(messageName string, values any) { + serviceMessageEnabled := viper.GetBool(constants.FlagEnableServiceMessages) + if !serviceMessageEnabled { + return + } + + teamCityEnvVar := os.Getenv("TEAMCITY_VERSION") + if teamCityEnvVar == "" { + p.printer.Error("service messages are only supported in TeamCity builds") + return + } + + switch t := values.(type) { + case string: + p.printer.Info(fmt.Sprintf("##teamcity[%s %s]\n", messageName, t)) + case map[string]string: + mapMsg := p.mapToStringMsg(t, messageName) + p.printer.Info(mapMsg) + default: + p.printer.Error("Unsupported service message value type") + } +} + +type OutputPrinter struct { + Out io.Writer + Err io.Writer +} + +func NewOutputPrinter(out io.Writer, err io.Writer) *OutputPrinter { + return &OutputPrinter{ + Out: out, + Err: err, + } +} + +func (p *OutputPrinter) Info(msg string) { + fmt.Fprint(p.Out, msg) +} + +func (p *OutputPrinter) Error(msg string) { + fmt.Fprint(p.Err, msg) +} + +func (p *provider) mapToStringMsg(m map[string]string, messageName string) string { + var builder strings.Builder + builder.WriteString(fmt.Sprintf("##teamcity[%s", messageName)) + for key, value := range m { + builder.WriteString(fmt.Sprintf(" %s=%s", key, value)) + } + builder.WriteString("]") + return builder.String() +} diff --git a/pkg/servicemessages/provider_test.go b/pkg/servicemessages/provider_test.go new file mode 100644 index 00000000..521ceec8 --- /dev/null +++ b/pkg/servicemessages/provider_test.go @@ -0,0 +1,63 @@ +package servicemessages + +import ( + "bytes" + "testing" + + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/spf13/viper" +) + +func TestServiceMessage(t *testing.T) { + tests := []struct { + name string + servicemessages bool + teamCityEnv bool + messsageName string + key string + value any + stdout *bytes.Buffer + stderr *bytes.Buffer + want string + wantErr string + }{ + {"service message flag is not enabled", false, false, "testMessage", "key1", "value1", &bytes.Buffer{}, &bytes.Buffer{}, "", ""}, + {"service message enabled with teamcity envvar and map value", true, true, "testMessage", "key1", map[string]string{"key": "value"}, &bytes.Buffer{}, &bytes.Buffer{}, "##teamcity[testMessage key=value]", ""}, + {"service message enabled without teamcity envvar", true, false, "testMessage", "key1", "value1", &bytes.Buffer{}, &bytes.Buffer{}, "", "service messages are only supported in TeamCity builds"}, + {"service message enabled with teamcity envvar and string value", true, true, "testMessage", "key1", "value", &bytes.Buffer{}, &bytes.Buffer{}, "##teamcity[testMessage value]\n", ""}, + {"service message enabled with teamcity envvar and unsupported value", true, true, "testMessage", "key1", []string{"dsdsd"}, &bytes.Buffer{}, &bytes.Buffer{}, "", "Unsupported service message value type"}, + } + + for _, tt := range tests { + setupArgs(t, constants.FlagEnableServiceMessages, tt.servicemessages) + setupEnvVar(t, "TEAMCITY_VERSION", "2021.1", tt.teamCityEnv) + t.Run(tt.name, func(t *testing.T) { + NewProvider(NewOutputPrinter(tt.stdout, tt.stderr)).ServiceMessage(tt.messsageName, tt.value) + if tt.want != "" { + got := tt.stdout.String() + if got != tt.want { + t.Errorf("Expected output:\n%s\nGot:\n%s", tt.want, got) + } + } + if tt.wantErr != "" { + e := tt.stderr.String() + if e != tt.wantErr { + t.Errorf("Expected error output:\n%s\nGot:\n%s", tt.wantErr, e) + } + } + }) + } +} + +func setupArgs(t *testing.T, key string, value bool) { + viper.Reset() + viper.Set(constants.FlagEnableServiceMessages, value) +} + +func setupEnvVar(t *testing.T, key, value string, set bool) { + if set { + t.Setenv(key, value) + } else { + t.Setenv(key, "") + } +} diff --git a/test/testutil/fakefactory.go b/test/testutil/fakefactory.go index f38744ec..25ac3cbf 100644 --- a/test/testutil/fakefactory.go +++ b/test/testutil/fakefactory.go @@ -1,6 +1,7 @@ package testutil import ( + "bytes" "errors" "net/http" "net/url" @@ -10,6 +11,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/config" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/servicemessages" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" ) @@ -54,17 +56,19 @@ func NewMockFactoryWithSpaceAndPrompt(api *MockHttpServer, space *spaces.Space, result := NewMockFactory(api) result.CurrentSpace = space result.AskProvider = askProvider + result.serviceMessageProvider = servicemessages.NewProvider(servicemessages.NewOutputPrinter(&bytes.Buffer{}, &bytes.Buffer{})) return result } type MockFactory struct { - api *MockHttpServer // must not be nil - SystemClient *octopusApiClient.Client // nil; lazily created like with the real factory - SpaceScopedClient *octopusApiClient.Client // nil; lazily created like with the real factory - CurrentSpace *spaces.Space - RawSpinner factory.Spinner - AskProvider question.AskProvider - ConfigProvider config.IConfigProvider + api *MockHttpServer // must not be nil + SystemClient *octopusApiClient.Client // nil; lazily created like with the real factory + SpaceScopedClient *octopusApiClient.Client // nil; lazily created like with the real factory + CurrentSpace *spaces.Space + RawSpinner factory.Spinner + AskProvider question.AskProvider + ConfigProvider config.IConfigProvider + serviceMessageProvider servicemessages.Provider } // refactor this later if there's ever a need for unit tests to vary the server url or API key (why would there be?) @@ -127,3 +131,6 @@ func (f *MockFactory) Ask(p survey.Prompt, response interface{}, opts ...survey. func (f *MockFactory) GetConfigProvider() (config.IConfigProvider, error) { return f.ConfigProvider, nil } +func (f *MockFactory) GetServiceMessageProvider() servicemessages.Provider { + return f.serviceMessageProvider +}