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
3 changes: 3 additions & 0 deletions pkg/cmd/config/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func listRun(cmd *cobra.Command) error {
Host string `json:"host"`
NoPrompt string `json:"noprompt"`
OutputFormat string `json:"outputformat"`
Shell string `json:"shell"`
Space string `json:"space"`
}

Expand All @@ -74,6 +75,8 @@ func listRun(cmd *cobra.Command) error {
configData.Space = configFile.GetString(key)
case strings.ToLower(constants.ConfigOutputFormat):
configData.OutputFormat = configFile.GetString(key)
case strings.ToLower(constants.ConfigShell):
configData.Shell = configFile.GetString(key)
default:
return fmt.Errorf("the key '%s' is not a supported config option", key)
}
Expand Down
12 changes: 10 additions & 2 deletions pkg/cmd/config/set/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/OctopusDeploy/cli/pkg/question"
"github.com/OctopusDeploy/cli/pkg/util/shell"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -67,13 +68,19 @@ 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.ConfigShell):
if err := shell.Validate(value); err != nil {
return err
}
localViper.Set(key, value)
default:
localViper.Set(key, value)
}
if err := localViper.WriteConfig(); err != nil {
Expand All @@ -91,6 +98,7 @@ func promptMissing(ask question.Asker, key string) (string, string, error) {
constants.ConfigOutputFormat,
constants.ConfigShowOctopus,
constants.ConfigEditor,
constants.ConfigShell,
// constants.ConfigProxyUrl,
}

Expand Down
6 changes: 5 additions & 1 deletion pkg/cmd/release/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/OctopusDeploy/cli/pkg/executor"
"github.com/OctopusDeploy/cli/pkg/question"
"github.com/OctopusDeploy/cli/pkg/surveyext"
"github.com/OctopusDeploy/cli/pkg/util/shell"
"github.com/OctopusDeploy/cli/test/fixtures"
"github.com/OctopusDeploy/cli/test/testutil"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels"
Expand Down Expand Up @@ -2025,6 +2026,9 @@ func TestDeployCreate_AutomationMode(t *testing.T) {

// this happens outside the scope of the normal AskQuestions flow so warrants its own integration-style test
func TestDeployCreate_GenerationOfAutomationCommand_MasksSensitiveVariables(t *testing.T) {
// pin the shell so the expected command doesn't depend on where the tests are run
t.Setenv(constants.EnvOctopusShell, string(shell.Bash))

const spaceID = "Spaces-1"
const fireProjectID = "Projects-22"

Expand Down Expand Up @@ -2202,7 +2206,7 @@ func TestDeployCreate_GenerationOfAutomationCommand_MasksSensitiveVariables(t *t
Package Download: Use cached packages (if available)
Deployment Targets: All included

Automation Command: octopus release deploy --space 'Default Space' --project 'Fire Project' --version '2.0' --environment 'dev' --variable 'Boring Variable:BORING' --variable 'Nuclear Launch Codes:*****' --variable 'Secret Password:*****' --no-prompt
Automation Command: octopus release deploy --space 'Default Space' --project 'Fire Project' --version 2.0 --environment dev --variable 'Boring Variable:BORING' --variable 'Nuclear Launch Codes:*****' --variable 'Secret Password:*****' --no-prompt
Warning: Command includes some sensitive variable values which have been replaced with placeholders.
Successfully started 2 deployment(s)

Expand Down
7 changes: 7 additions & 0 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package root

import (
"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,6 +30,7 @@ import (
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/OctopusDeploy/cli/pkg/question"
"github.com/OctopusDeploy/cli/pkg/util/shell"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -96,6 +100,8 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro

cmdPFlags.BoolP(constants.FlagNoPrompt, "", false, "Disable prompting in interactive mode")

cmdPFlags.String(constants.FlagShell, "", fmt.Sprintf(`Specify the shell that generated automation commands are quoted for (%s); defaults to the shell the CLI is running under`, strings.Join(shell.Names, ", ")))

// 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)
Expand All @@ -112,6 +118,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.ConfigShell, cmdPFlags.Lookup(constants.FlagShell))
_ = 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
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func setDefaults(v *viper.Viper) {
// v.SetDefault(constants.ConfigProxyUrl, "")
v.SetDefault(constants.ConfigShowOctopus, true)
v.SetDefault(constants.ConfigOutputFormat, "table")
v.SetDefault(constants.ConfigShell, "")

if runtime.GOOS == "windows" {
v.SetDefault(constants.ConfigEditor, "notepad")
Expand Down Expand Up @@ -58,6 +59,9 @@ func bindEnvironment(v *viper.Viper) error {
if err := v.BindEnv(constants.ConfigNoPrompt, constants.EnvCI); err != nil {
return err
}
if err := v.BindEnv(constants.ConfigShell, constants.EnvOctopusShell); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const (
FlagOutputFormatLegacy = "outputFormat"
FlagNoPrompt = "no-prompt"
FlagEnableServiceMessages = "enable-service-messages"
FlagShell = "shell"
)

// flags for storing things in the go context
Expand All @@ -38,13 +39,15 @@ const (
ConfigEditor = "Editor"
ConfigShowOctopus = "ShowOctopus"
ConfigOutputFormat = "OutputFormat"
ConfigShell = "Shell"
)

const (
EnvOctopusUrl = "OCTOPUS_URL"
EnvOctopusApiKey = "OCTOPUS_API_KEY"
EnvOctopusAccessToken = "OCTOPUS_ACCESS_TOKEN"
EnvOctopusSpace = "OCTOPUS_SPACE"
EnvOctopusShell = "OCTOPUS_SHELL"
EnvEditor = "EDITOR"
EnvVisual = "VISUAL"
EnvCI = "CI"
Expand Down
21 changes: 15 additions & 6 deletions pkg/util/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package flag

import (
"fmt"
"strings"

"github.com/OctopusDeploy/cli/pkg/util/shell"
)

type Flag[T any] struct {
Expand Down Expand Up @@ -60,27 +61,35 @@ func New[T any](name string, secure bool) *Flag[T] {
// GenerateAutomationCmd generates the command that can be used to achive
// the same results with the CLI in automation mode.
func GenerateAutomationCmd(cmdPath string, space string, flags ...Generatable) string {
return GenerateAutomationCmdForShell(shell.Current(), cmdPath, space, flags...)
}

// GenerateAutomationCmdForShell generates the automation command, quoting values using
// the rules of the given shell.
func GenerateAutomationCmdForShell(sh shell.Shell, cmdPath string, space string, flags ...Generatable) string {
quote := func(value string) string { return shell.Quote(sh, value) }

autoCmd := cmdPath
if space != "" {
autoCmd += fmt.Sprintf(" --space '%s'", strings.ReplaceAll(space, "'", "'\\''"))
autoCmd += fmt.Sprintf(" --space %s", quote(space))
}
for _, flag := range flags {
switch value := flag.GetValue().(type) {
case string:
if value != "" {
if flag.IsSecure() {
autoCmd += fmt.Sprintf(" --%s '***'", flag.GetName())
autoCmd += fmt.Sprintf(" --%s %s", flag.GetName(), quote("***"))
continue
}
autoCmd += fmt.Sprintf(" --%s '%s'", flag.GetName(), strings.ReplaceAll(value, "'", "'\\''"))
autoCmd += fmt.Sprintf(" --%s %s", flag.GetName(), quote(value))
}
case []string:
for _, val := range value {
if flag.IsSecure() {
autoCmd += fmt.Sprintf(" --%s '***'", flag.GetName())
autoCmd += fmt.Sprintf(" --%s %s", flag.GetName(), quote("***"))
continue
}
autoCmd += fmt.Sprintf(" --%s '%s'", flag.GetName(), strings.ReplaceAll(val, "'", "'\\''"))
autoCmd += fmt.Sprintf(" --%s %s", flag.GetName(), quote(val))
}
case bool:
if value {
Expand Down
62 changes: 62 additions & 0 deletions pkg/util/flag/flag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package flag_test

import (
"testing"

"github.com/OctopusDeploy/cli/pkg/util/flag"
"github.com/OctopusDeploy/cli/pkg/util/shell"
"github.com/stretchr/testify/assert"
)

func TestGenerateAutomationCmdForShell(t *testing.T) {
project := flag.New[string]("project", false)
project.Value = "Soft Drinks"
version := flag.New[string]("version", false)
version.Value = "0.0.3"
environments := flag.New[[]string]("environment", false)
environments.Value = []string{"Dev", "Test Environment"}
tenantTag := flag.New[string]("tenant-tag", false)
tenantTag.Value = "Regions/us-east"
force := flag.New[bool]("force-package-download", false)
force.Value = true
timeout := flag.New[int]("timeout", false)
timeout.Value = 30
password := flag.New[string]("password", true)
password.Value = "hunter2"
empty := flag.New[string]("description", false)

flags := []flag.Generatable{project, version, environments, tenantTag, force, timeout, password, empty}

tests := []struct {
shell shell.Shell
expected string
}{
{
shell.Bash,
`octopus release deploy --space 'Default Space' --project 'Soft Drinks' --version 0.0.3 --environment Dev --environment 'Test Environment' --tenant-tag Regions/us-east --force-package-download --timeout 30 --password '***' --no-prompt`,
},
{
shell.PowerShell,
`octopus release deploy --space 'Default Space' --project 'Soft Drinks' --version 0.0.3 --environment Dev --environment 'Test Environment' --tenant-tag Regions/us-east --force-package-download --timeout 30 --password '***' --no-prompt`,
},
{
shell.Cmd,
`octopus release deploy --space "Default Space" --project "Soft Drinks" --version 0.0.3 --environment Dev --environment "Test Environment" --tenant-tag Regions/us-east --force-package-download --timeout 30 --password "***" --no-prompt`,
},
}

for _, test := range tests {
t.Run(string(test.shell), func(t *testing.T) {
actual := flag.GenerateAutomationCmdForShell(test.shell, "octopus release deploy", "Default Space", flags...)
assert.Equal(t, test.expected, actual)
})
}
}

func TestGenerateAutomationCmdForShell_NoSpace(t *testing.T) {
name := flag.New[string]("name", false)
name.Value = "Dev"

actual := flag.GenerateAutomationCmdForShell(shell.Bash, "octopus environment create", "", name)
assert.Equal(t, "octopus environment create --name Dev --no-prompt", actual)
}
9 changes: 9 additions & 0 deletions pkg/util/shell/parent_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package shell

// parentProcessName is only used to tell cmd and PowerShell apart, so there is
// nothing to look up on unix.
func parentProcessName() string {
return ""
}
29 changes: 29 additions & 0 deletions pkg/util/shell/parent_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build windows

package shell

import (
"os"
"syscall"
"unsafe"
)

// parentProcessName returns the file name of the executable that launched us.
func parentProcessName() string {
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0)
if err != nil {
return ""
}
defer syscall.CloseHandle(snapshot)

entry := syscall.ProcessEntry32{}
entry.Size = uint32(unsafe.Sizeof(entry))
ppid := uint32(os.Getppid())

for err = syscall.Process32First(snapshot, &entry); err == nil; err = syscall.Process32Next(snapshot, &entry) {
if entry.ProcessID == ppid {
return syscall.UTF16ToString(entry.ExeFile[:])
}
}
return ""
}
Loading