Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions pkg/cmd/config/set/set.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package set

import (
"errors"
"fmt"
"strconv"
"strings"
Expand Down Expand Up @@ -67,13 +68,21 @@ func setRun(isPromptEnabled bool, ask question.Asker, key string, value string)
key = k
}
key = strings.ToLower(key)
if key == strings.ToLower(constants.ConfigNoPrompt) {
switch key {
case strings.ToLower(constants.ConfigNoPrompt):
boolValue, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("the provided value %s is not valid for NoPrompt, please use true of false", value)
}
localViper.Set(key, boolValue)
} else {
case strings.ToLower(constants.ConfigOutputFormat):
// reject it here rather than let it sit in the config file poisoning every later command
value = strings.ToLower(strings.TrimSpace(value))
if !constants.IsValidOutputFormat(value) {
return errors.New(constants.UnsupportedOutputFormatMessage(value))
}
localViper.Set(key, value)
default:
localViper.Set(key, value)
}
if err := localViper.WriteConfig(); err != nil {
Expand Down
79 changes: 73 additions & 6 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package root

import (
"errors"
"fmt"
"strings"

"github.com/OctopusDeploy/cli/pkg/apiclient"
accountCmd "github.com/OctopusDeploy/cli/pkg/cmd/account"
apiCmd "github.com/OctopusDeploy/cli/pkg/cmd/api"
Expand All @@ -27,7 +31,9 @@ import (
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/OctopusDeploy/cli/pkg/question"
"github.com/OctopusDeploy/cli/pkg/usage"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)

Expand Down Expand Up @@ -114,9 +120,9 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro
_ = 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
// so we'll get bad values. PersistentPreRunE is a convenient callback for setting up our
// environment after parsing but before execution.
cmd.PersistentPreRun = func(_ *cobra.Command, _ []string) {
cmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
// map flag alias values
for k, v := range flagAliases {
for _, aliasName := range v {
Expand All @@ -128,16 +134,33 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro
}
}

if noPrompt := viper.GetBool(constants.ConfigNoPrompt); noPrompt {
noPrompt := viper.GetBool(constants.ConfigNoPrompt)
if noPrompt {
askProvider.DisableInteractive()
if v, _ := cmdPFlags.GetString(constants.FlagOutputFormat); v == "" {
cmdPFlags.Set(constants.FlagOutputFormat, constants.OutputFormatBasic)
}
}

// resolve the output format once, here, rather than leaving each command to work it
// out for itself; commands (and output.PrintResource / output.PrintArray) then just
// read the flag and can trust what they get.
configuredFormat := ""
if viper.InConfig(strings.ToLower(constants.ConfigOutputFormat)) {
configuredFormat = viper.GetString(constants.ConfigOutputFormat)
}
outputFormat, warning, err := resolveOutputFormat(cmdPFlags, noPrompt, configuredFormat)
if warning != "" {
cmd.PrintErrln(warning)
}
if err != nil {
return usage.NewUsageError(err.Error(), cmd)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: an invalid OutputFormat config value now soft-bricks the whole CLI, including the command that would fix it.

octopus config set OutputFormat xml succeeds today — pkg/cmd/config/set/set.go validates only the key (and NoPrompt's bool), never the OutputFormat value, and the interactive prompt accepts free text. Once that value is in the config file, this pre-run returns a usage error for every invocation: octopus config set OutputFormat table, octopus config list, octopus help, octopus --version, and shell completion (__complete) all die before their RunE runs. The only ways out are hand-editing the config file or guessing the non-obvious escape hatch of appending an explicit -f table (which wins via Changed).

Two complementary fixes:

  • validate the value in config set with the new constants.IsValidOutputFormat (write-side), and
  • for a config-sourced (as opposed to flag-sourced) invalid value, warn and fall back to the default instead of hard-failing, so a bad file never locks the user out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 883e7ef, with a regression test added in dd307dc. Both halves, as suggested:

  • Write sidesetRun now has a case strings.ToLower(constants.ConfigOutputFormat) alongside the NoPrompt one, which lowercases/trims and rejects anything constants.IsValidOutputFormat doesn't know before localViper.Set. The interactive path lands in the same place: promptMissing only fills value in, then falls through to the same switch, so free text typed at the prompt is rejected too.
  • Read sideresolveOutputFormat treats a bad config-sourced value as a warning on stderr and carries on down the precedence chain, so an already-poisoned file degrades to the default. A bad value from the flag is still a hard usage error, since that one the user just typed.

I built the binary and ran it against a config file containing "OutputFormat": "xml", with HOME pointed at a scratch dir, walking every escape route named above:

$ octopus --version                     -> 2.23.10                          exit 0
$ octopus help                          -> help text                        exit 0
$ octopus config list                   -> table of config                  exit 0
$ octopus __complete config set ""      -> ":0 / ShellCompDirectiveDefault"  exit 0
$ octopus config get OutputFormat       -> xml                              exit 0
$ octopus config set OutputFormat table --no-prompt                         exit 0
    config file afterwards: "outputformat": "table"
$ octopus config list                   -> no warning, table                exit 0

Each of the first six printed Ignoring the OutputFormat config setting: unsupported output format 'xml'. Valid values are 'json', 'table', 'basic' and then did its job, so the fix-it command runs without needing the -f table escape hatch, and once it has run the warning stops.

Three things I checked rather than assumed, since "can't lock the user out" is the whole point:

  1. The warning can't corrupt machine-readable output. It goes out via cmd.PrintErrln, so with the bad config still in place octopus config list -f json 2>/dev/null prints clean JSON, and the warning shows up only under 2>&1 1>/dev/null.
  2. No other startup path reads the config value unvalidated. ConfigOutputFormat is only SetDefault plus the config file (pkg/config/config.go:32) — it is not in bindEnvironment, so there is no OCTOPUS_*-shaped way back in, and viper.InConfig consults the file map only. No subcommand defines its own PersistentPreRunE (grep for it finds only root.go), so no command can skip the resolution and reach the raw value.
  3. Valid-but-oddly-spelled config values aren't thrown away by the new warning. IsValidOutputFormat lowercases and the check trims, so "JSON", " table " and "Basic" in the config file are honoured rather than warned about. Confirmed by running those three through the binary.

Unit coverage: TestNewCmdRoot_PreRunWarnsRatherThanFailingForAnUnsupportedConfigFileValue feeds the global viper a {"outputformat":"xml"} config file, drives the real PersistentPreRunE, and asserts no error, a resolved table, and the warning on stderr. Disabling the new config-value branch fails it with Received unexpected error: unsupported output format 'xml', so it pins the lockout rather than the wording. TestResolveOutputFormat_AnExplicitFlagStillWinsOverAnUnsupportedConfigFileValue covers flag-beats-bad-config.

Residual, stated plainly: config set's own rejection has no unit test. The set package has no test file and setRun writes to the real config path via config.EnsureConfigPath(), so testing it needs either a filesystem seam or an injected viper. I only exercised it through the binary (octopus config set OutputFormat xml exits 1 and leaves the file untouched). Happy to add the seam if you want that covered here rather than in a follow-up.

}
// write through Value so the flag isn't marked as Changed; commands such as `task wait`
// read Changed() to mean "the user explicitly asked for a format"
_ = cmdPFlags.Lookup(constants.FlagOutputFormat).Value.Set(outputFormat)

if spaceNameOrId := viper.GetString(constants.ConfigSpace); spaceNameOrId != "" {
clientFactory.SetSpaceNameOrId(spaceNameOrId)
}
return nil
}

cmd.RunE = func(cmd *cobra.Command, args []string) error {
Expand All @@ -150,3 +173,47 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro

return cmd
}

// resolveOutputFormat works out the output format a command should use, in precedence order:
// an explicit --output-format (or legacy --outputFormat) flag, then the OutputFormat config file
// setting, then basic when prompting is disabled, and finally table.
//
// Note the flag carries a non-empty default, so "did the caller ask for a format?" has to be
// answered with Changed() rather than by testing the value for emptiness. configuredFormat is
// the OutputFormat config file setting, or empty if the config file doesn't set one.
//
// An unusable value returns an error, except when it came from the config file, which we can
// only warn about; see below.
func resolveOutputFormat(flags *pflag.FlagSet, noPrompt bool, configuredFormat string) (string, string, error) {
// the legacy flag is copied onto the new one by value, which doesn't mark it as Changed
explicit := flags.Changed(constants.FlagOutputFormat) || flags.Changed(constants.FlagOutputFormatLegacy)
outputFormat, _ := flags.GetString(constants.FlagOutputFormat)

// this runs for every command, so failing hard on a bad config file value would lock the
// user out of the whole CLI - `octopus config set OutputFormat table` included. Warn and
// carry on down the precedence chain instead, so the config is still fixable.
warning := ""
if configuredFormat != "" && !constants.IsValidOutputFormat(strings.TrimSpace(configuredFormat)) {
warning = fmt.Sprintf("Ignoring the %s config setting: %s",
constants.ConfigOutputFormat, constants.UnsupportedOutputFormatMessage(configuredFormat))
configuredFormat = ""
}

switch {
case explicit: // take the flag as given
case configuredFormat != "":
outputFormat = configuredFormat
// note noPrompt is bound to $CI as well as --no-prompt (see config.bindEnvironment), so
// this fires on essentially every CI pipeline, not just on an explicit --no-prompt
case noPrompt:
outputFormat = constants.OutputFormatBasic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blast radius on the disclosed --no-promptbasic change: it fires on every CI run, not just explicit --no-prompt.

ConfigNoPrompt is bound to the CI environment variable (pkg/config/config.go:58), and GitHub Actions, GitLab CI, CircleCI, Travis etc. all set CI=true. So this branch changes the default stdout of every command in essentially all CI pipelines from table to basic — anyone parsing/snapshotting table output breaks, without ever having passed --no-prompt. The PR description flags this as needing a call; noting here that the trigger is broader than the flag name suggests, which argues for either the delete-the-dead-branch option or a BREAKING CHANGE footer if kept.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The finding holds, and it's worse than "broader trigger": on main this branch is unreachable, so keeping it is a new behaviour change rather than a fix to an existing one.

What I confirmed:

  • bindEnvironment binds ConfigNoPrompt to constants.EnvCI (pkg/config/config.go:58), so CI=true — set by GitHub Actions, GitLab CI, CircleCI, Travis, Buildkite, Azure Pipelines — resolves noPrompt true with no --no-prompt anywhere. Reproduced with the built binary: CI=true octopus config list against a config with no OutputFormat prints the basic one-value-per-line form; the same command without CI prints the KEY/VALUE table.
  • On main the guard is if v, _ := cmdPFlags.GetString(FlagOutputFormat); v == "" while the flag is registered with a constants.OutputFormatTable default (root.go:95 on main), so v is never empty and the basic write never happens. --no-prompt has therefore never changed the output format on main. Deleting the branch is a no-op against shipped behaviour; keeping it changes default stdout for every command in essentially every pipeline.

So the choice isn't "fix vs. don't fix", it's "introduce this now or not". My read is that tablebasic is not a safe default flip for CI: it's a format change on stdout for scripts that never opted into anything, and the people affected are exactly the ones most likely to be parsing or snapshotting it. Against that, basic is arguably what a non-interactive caller wants, and the --no-prompt flag has advertised itself as the non-interactive switch all along.

For now 66d4de8 only writes the $CI trigger down in a comment next to the branch, so whoever reads it next isn't surprised. I have deliberately not made the call.

Open question: delete the case noPrompt: basic branch in this PR so the format stays table everywhere and this PR is a pure bug fix — or keep it and add a BREAKING CHANGE: footer naming $CI, not just --no-prompt, as the trigger? If you want it kept, do you also want it narrowed to an explicit --no-prompt/config set NoPrompt (i.e. not the $CI env binding), which would keep the flag honest without touching CI defaults?

default:
outputFormat = constants.OutputFormatTable
}

outputFormat = strings.ToLower(strings.TrimSpace(outputFormat))
if !constants.IsValidOutputFormat(outputFormat) {
return "", warning, errors.New(constants.UnsupportedOutputFormatMessage(outputFormat))
}
return outputFormat, warning, nil
}
175 changes: 175 additions & 0 deletions pkg/cmd/root/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package root

import (
"bytes"
"strings"
"testing"

"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// newOutputFormatFlags mirrors the way NewCmdRoot registers the output format flags,
// including the non-empty default which is what makes Changed() necessary.
func newOutputFormatFlags() *pflag.FlagSet {
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.StringP(constants.FlagOutputFormat, "f", constants.OutputFormatTable, "")
flags.String(constants.FlagOutputFormatLegacy, "", "")
return flags
}

func TestResolveOutputFormat(t *testing.T) {
tests := []struct {
name string
flag string // --output-format, empty means not supplied
legacyFlag string // --outputFormat, empty means not supplied
noPrompt bool
configuredFormat string
expected string
}{
{name: "defaults to table", expected: constants.OutputFormatTable},
{name: "explicit flag is honoured", flag: "json", expected: constants.OutputFormatJson},
{name: "explicit flag is normalised", flag: " JSON ", expected: constants.OutputFormatJson},
{name: "legacy flag is honoured", legacyFlag: "json", expected: constants.OutputFormatJson},
{name: "config file setting is honoured", configuredFormat: "json", expected: constants.OutputFormatJson},
{name: "flag beats config file", flag: "basic", configuredFormat: "json", expected: constants.OutputFormatBasic},
{name: "legacy flag beats config file", legacyFlag: "basic", configuredFormat: "json", expected: constants.OutputFormatBasic},
// the flag's non-empty default used to mask this, so --no-prompt never took effect
{name: "no-prompt falls back to basic", noPrompt: true, expected: constants.OutputFormatBasic},
{name: "flag beats no-prompt", flag: "json", noPrompt: true, expected: constants.OutputFormatJson},
{name: "explicitly requesting table beats no-prompt", flag: "table", noPrompt: true, expected: constants.OutputFormatTable},
{name: "config file beats no-prompt", noPrompt: true, configuredFormat: "json", expected: constants.OutputFormatJson},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
flags := newOutputFormatFlags()
if test.flag != "" {
assert.NoError(t, flags.Set(constants.FlagOutputFormat, test.flag))
}
if test.legacyFlag != "" {
assert.NoError(t, flags.Set(constants.FlagOutputFormatLegacy, test.legacyFlag))
// NewCmdRoot copies the legacy value across without marking the new flag as Changed
assert.NoError(t, flags.Lookup(constants.FlagOutputFormat).Value.Set(test.legacyFlag))
}

actual, warning, err := resolveOutputFormat(flags, test.noPrompt, test.configuredFormat)

assert.NoError(t, err)
assert.Empty(t, warning)
assert.Equal(t, test.expected, actual)
})
}
}

func TestResolveOutputFormat_RejectsUnsupportedFormats(t *testing.T) {
// commands that hand-roll their own format switch have no default case, so an unsupported
// format used to print nothing at all and exit 0
tests := []struct {
name string
flag string
legacyFlag string
}{
{name: "from the flag", flag: "xml"},
{name: "from the legacy flag", legacyFlag: "yaml"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
flags := newOutputFormatFlags()
if test.flag != "" {
assert.NoError(t, flags.Set(constants.FlagOutputFormat, test.flag))
}
if test.legacyFlag != "" {
assert.NoError(t, flags.Set(constants.FlagOutputFormatLegacy, test.legacyFlag))
assert.NoError(t, flags.Lookup(constants.FlagOutputFormat).Value.Set(test.legacyFlag))
}

_, _, err := resolveOutputFormat(flags, false, "")

assert.ErrorContains(t, err, "unsupported output format")
})
}
}

// an unsupported value in the config file must not be fatal: this runs ahead of every command,
// so failing hard would lock the user out of the `config set` that would fix it
func TestResolveOutputFormat_WarnsAndFallsBackForAnUnsupportedConfigFileValue(t *testing.T) {
flags := newOutputFormatFlags()

actual, warning, err := resolveOutputFormat(flags, false, "csv")

assert.NoError(t, err)
assert.Equal(t, constants.OutputFormatTable, actual)
assert.Contains(t, warning, "unsupported output format 'csv'")
assert.Contains(t, warning, constants.ConfigOutputFormat)
}

func TestResolveOutputFormat_AnExplicitFlagStillWinsOverAnUnsupportedConfigFileValue(t *testing.T) {
flags := newOutputFormatFlags()
assert.NoError(t, flags.Set(constants.FlagOutputFormat, "json"))

actual, warning, err := resolveOutputFormat(flags, false, "csv")

assert.NoError(t, err)
assert.Equal(t, constants.OutputFormatJson, actual)
assert.NotEmpty(t, warning)
}

// the resolved format is written back through the flag's Value rather than FlagSet.Set,
// because Set marks the flag Changed and the flag object is shared with every subcommand.
// pkg/cmd/task/wait reads Changed(FlagOutputFormat) to mean "the user asked for a format",
// so marking it here would silently switch a plain `octopus task wait <id>` off the legacy
// progress formatter.
func TestNewCmdRoot_PreRunDoesNotMarkTheOutputFormatFlagAsChanged(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
cmd := NewCmdRoot(nil, nil, nil)

require.NoError(t, cmd.PersistentPreRunE(cmd, nil))

flags := cmd.PersistentFlags()
value, err := flags.GetString(constants.FlagOutputFormat)
require.NoError(t, err)
assert.Equal(t, constants.OutputFormatTable, value)
assert.False(t, flags.Changed(constants.FlagOutputFormat))
assert.False(t, flags.Changed(constants.FlagOutputFormatLegacy))
}

// the pre-run runs ahead of every command, so an unsupported value sitting in the config file
// must not be fatal - that would lock the user out of the `config set` that fixes it
func TestNewCmdRoot_PreRunWarnsRatherThanFailingForAnUnsupportedConfigFileValue(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
cmd := NewCmdRoot(nil, nil, nil)
viper.SetConfigType("json")
require.NoError(t, viper.ReadConfig(strings.NewReader(`{"outputformat":"xml"}`)))
stderr := &bytes.Buffer{}
cmd.SetErr(stderr)

require.NoError(t, cmd.PersistentPreRunE(cmd, nil))

value, err := cmd.PersistentFlags().GetString(constants.FlagOutputFormat)
require.NoError(t, err)
assert.Equal(t, constants.OutputFormatTable, value)
// the warning goes to stderr so it can't corrupt `-f json` output on stdout
assert.Contains(t, stderr.String(), "unsupported output format 'xml'")
}

func TestUnsupportedOutputFormatMessage(t *testing.T) {
assert.Equal(t,
"unsupported output format ''. Valid values are 'json', 'table', 'basic'",
constants.UnsupportedOutputFormatMessage(""))
}

func TestIsValidOutputFormat(t *testing.T) {
assert.True(t, constants.IsValidOutputFormat(constants.OutputFormatJson))
assert.True(t, constants.IsValidOutputFormat(constants.OutputFormatTable))
assert.True(t, constants.IsValidOutputFormat(constants.OutputFormatBasic))
assert.True(t, constants.IsValidOutputFormat("JSON"), "should be case-insensitive")
assert.False(t, constants.IsValidOutputFormat(""))
assert.False(t, constants.IsValidOutputFormat("xml"))
}
23 changes: 23 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package constants

import (
"fmt"
"strings"
)

const (
ExecutableName = "octopus"
)
Expand Down Expand Up @@ -77,6 +82,24 @@ const (
PromptCreateNew = "<Create New>"
)

// IsValidOutputFormat tells you whether outputFormat is one the CLI understands.
// The comparison is case-insensitive, matching the way commands render the format.
func IsValidOutputFormat(outputFormat string) bool {
switch strings.ToLower(outputFormat) {
case OutputFormatJson, OutputFormatTable, OutputFormatBasic:
return true
default:
return false
}
}

// UnsupportedOutputFormatMessage is the message we give back when we're handed an output
// format we don't understand. It lives next to IsValidOutputFormat so the wording can't drift
// between the places that reject one.
func UnsupportedOutputFormatMessage(outputFormat string) string {
return fmt.Sprintf("unsupported output format '%s'. Valid values are 'json', 'table', 'basic'", outputFormat)
}

// IsProgrammaticOutputFormat tells you if it is acceptable for your command to
// print miscellaneous output to stdout, such as progress messages.
// If your command is capable of printing such things, you should check the output format
Expand Down
5 changes: 1 addition & 4 deletions pkg/output/print_array.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package output
import (
"encoding/json"
"errors"
"fmt"
"strings"

"github.com/OctopusDeploy/cli/pkg/constants"
Expand Down Expand Up @@ -64,9 +63,7 @@ func PrintArray[T any](items []T, cmd *cobra.Command, mappers Mappers[T]) error
return t.Print()

default:
return usage.NewUsageError(
fmt.Sprintf("unsupported output format %s. Valid values are 'json', 'table', 'basic'. Defaults to table", outputFormat),
cmd)
return usage.NewUsageError(constants.UnsupportedOutputFormatMessage(outputFormat), cmd)
}
return nil
}
Loading