Skip to content
Open
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
11 changes: 8 additions & 3 deletions .github/docs/contribution-guide/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ import (

// Define consts for command flags
const (
someArg = "MY_ARG"
someFlag = "my-flag"
someArg = "MY_ARG"
someFlag = "my-flag"
secretFlag = "secret"
)

// Struct to model user input (arguments and/or flags)
type inputModel struct {
*globalflags.GlobalFlagModel
MyArg string
MyFlag *string
Secret *string
}

// "bar" command constructor
Expand Down Expand Up @@ -85,8 +87,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
}

// Configure command flags (type, default value, and description)
func configureFlags(cmd *cobra.Command) {
func configureFlags(cmd *cobra.Command, params *types.CmdParams) {
cmd.Flags().StringP(someFlag, "shorthand", "defaultValue", "My flag description")
secret := flags.SecretFlag(secretFlag, params)
cmd.Flags().Var(secret, secretFlag, secret.Usage())
}

// Parse user input (arguments and/or flags)
Expand All @@ -102,6 +106,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
GlobalFlagModel: globalFlags,
MyArg: myArg,
MyFlag: flags.FlagToStringPointer(p, cmd, someFlag),
Secret: flags.SecretFlagToStringPointer(p, cmd, secretFlag),
}

// Write the input model to the debug logs
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ For prints that are specific to a certain log level, you can use the methods def

For command outputs that should always be displayed, no matter the defined verbosity, you should use the `print` methods `Outputf` and `Outputln`. These should only be used for the actual output of the commands, which can usually be described by "I ran the command to see _this_".

#### Handling secrets

If your command needs secrets as input, please make sure to use `flags.SecretFlag()` and `flags.SecretFlagToStringPointer()`.
These functions implement reading from stdin or a file.

They also support reading the secret value as a command line argument (deprecated, marked for removal in Oct 2026).

### Onboarding a new STACKIT service

If you want to add a command that uses a STACKIT service `foo` that was not yet used by the CLI, you will first need to implement a few extra steps to configure the new service:
Expand Down
4 changes: 2 additions & 2 deletions docs/stackit_beta_alb_observability-credentials_add.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ stackit beta alb observability-credentials add [flags]

```
Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag
$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy
$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --displayname yyy
```

### Options

```
-d, --displayname string Displayname for the credentials
-h, --help Help for "stackit beta alb observability-credentials add"
--password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).
--password string password. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty.
-u, --username string Username for the credentials
```

Expand Down
12 changes: 7 additions & 5 deletions internal/cmd/beta/alb/observability-credentials/add/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
Example: examples.Build(
examples.NewExample(
`Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag`,
"$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy"),
"$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --displayname yyy"),
),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
Expand Down Expand Up @@ -71,14 +71,16 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
return outputResult(params.Printer, model.OutputFormat, resp)
},
}
configureFlags(cmd)
configureFlags(cmd, params)
return cmd
}

func configureFlags(cmd *cobra.Command) {
func configureFlags(cmd *cobra.Command, params *types.CmdParams) {
cmd.Flags().StringP(usernameFlag, "u", "", "Username for the credentials")
cmd.Flags().StringP(displaynameFlag, "d", "", "Displayname for the credentials")
cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`)

password := flags.SecretFlag(passwordFlag, params)
cmd.Flags().Var(password, passwordFlag, password.Usage())

cobra.CheckErr(flags.MarkFlagsRequired(cmd, usernameFlag, displaynameFlag))
}
Expand All @@ -90,7 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel,
GlobalFlagModel: globalFlags,
Username: flags.FlagToStringPointer(p, cmd, usernameFlag),
Displayname: flags.FlagToStringPointer(p, cmd, displaynameFlag),
Password: flags.FlagToStringPointer(p, cmd, passwordFlag),
Password: flags.SecretFlagToStringPointer(p, cmd, passwordFlag),
}

p.DebugInputModel(model)
Expand Down
79 changes: 79 additions & 0 deletions internal/pkg/flags/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package flags

import (
"fmt"
"io/fs"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"

Check failure on line 9 in internal/pkg/flags/secret.go

View workflow job for this annotation

GitHub Actions / CI

File is not properly formatted (goimports)
"github.com/stackitcloud/stackit-cli/internal/pkg/types"

"github.com/stackitcloud/stackit-cli/internal/pkg/print"
)

type secretFlag struct {
printer *print.Printer
fs fs.FS
value string
name string
}


Check failure on line 22 in internal/pkg/flags/secret.go

View workflow job for this annotation

GitHub Actions / CI

File is not properly formatted (gofmt)
func SecretFlag(name string, params *types.CmdParams) *secretFlag {
f := &secretFlag{
printer: params.Printer,
fs: params.Fs,
name: name,
}
return f
}

var _ pflag.Value = &secretFlag{}

func (f *secretFlag) String() string {
return f.value
}

func (f *secretFlag) Set(value string) error {
if strings.HasPrefix(value, "@") {
path := strings.Trim(value[1:], `"'`)
bytes, err := fs.ReadFile(f.fs, path)
if err != nil {
return fmt.Errorf("reading secret %s: %w", f.name, err)
}
f.value = string(bytes)
return nil
}
f.printer.Warn("Passing a secret value on the command line is insecure and deprecated. This usage will stop working October 2026.\n")
f.value = value
return nil
}

func (f *secretFlag) Type() string {
return "string"
}

func (f *secretFlag) Usage() string {
return fmt.Sprintf("%s. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty.", f.name)
}

func SecretFlagToStringPointer(p *print.Printer, cmd *cobra.Command, flag string) *string {
value, err := cmd.Flags().GetString(flag)
if err != nil {
p.Debug(print.ErrorLevel, "convert secret flag to string pointer: %v", err)
return nil
}
if value == "" {
input, err := p.PromptForPassword(fmt.Sprintf("enter %s: ", flag))
if err != nil {
p.Debug(print.ErrorLevel, "convert secret flag %q to string pointer: %v", flag, err)
return nil
}
return &input
}
if cmd.Flag(flag).Changed {
return &value
}
return nil
}
123 changes: 123 additions & 0 deletions internal/pkg/flags/secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package flags

import (
"io"
"testing"
"testing/fstest"

"github.com/spf13/cobra"

Check failure on line 8 in internal/pkg/flags/secret_test.go

View workflow job for this annotation

GitHub Actions / CI

File is not properly formatted (goimports)
"github.com/stackitcloud/stackit-cli/internal/pkg/testparams"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
)

type testFile struct {
path, content string
}

func TestSecretFlag(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want *string
file *testFile
stdin string
wantErr bool
wantStdErr string
}{
{
name: "no value: prompts",
value: "",
want: utils.Ptr("from stdin"),
stdin: "from stdin",
},
{
name: "a value: prints deprecation",
value: "a value",
want: utils.Ptr("a value"),
wantStdErr: "Warning: Passing a secret value on the command line is insecure and deprecated. This usage will stop working October 2026.\n",
},
{
name: "from an existing file",
value: "@some-file.txt",
want: utils.Ptr("from file"),
file: &testFile{
path: "some-file.txt",
content: "from file",
},
},
{
name: "from a non-existing file",
value: "@some-file-with-typo.txt",
wantErr: true,
file: &testFile{
path: "some-file.txt",
content: "from file",
},
},
{
name: "from an existing double-quoted file",
value: `@"some-file.txt"`,
want: utils.Ptr("from file"),
file: &testFile{
path: "some-file.txt",
content: "from file",
},
},
{
name: "from an existing single-quoted file",
value: "@'some-file.txt'",
want: utils.Ptr("from file"),
file: &testFile{
path: "some-file.txt",
content: "from file",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := testparams.NewTestParams()
if tt.file != nil {
params.CmdParams.Fs = fstest.MapFS{

Check failure on line 83 in internal/pkg/flags/secret_test.go

View workflow job for this annotation

GitHub Actions / CI

QF1008: could remove embedded field "CmdParams" from selector (staticcheck)
tt.file.path: &fstest.MapFile{
Data: []byte(tt.file.content),
},
}
}
flag := SecretFlag("test", params.CmdParams)
cmd := cobra.Command{}
cmd.Flags().Var(flag, "test", flag.Usage())
if tt.stdin != "" {
params.In.WriteString(tt.stdin)
params.In.WriteString("\n")
}

if tt.value != "" { // emulate pflag only calling set when flag is specified on the command line
err := cmd.Flags().Set("test", tt.value)
if err != nil && !tt.wantErr {
t.Fatalf("unexpected error: %v", err)
}
if err == nil && tt.wantErr {
t.Fatalf("expected error, got none")
}
}

got := SecretFlagToStringPointer(params.Printer, &cmd, "test")

if got != tt.want && *got != *tt.want {
t.Fatalf("unexpected value: got %q, want %q", *got, *tt.want)
}
if tt.wantStdErr != "" {
message, err := params.Err.ReadString('\n')
if err != nil && err != io.EOF {
t.Fatalf("reading stderr: %v", err)
}
if message != tt.wantStdErr {
t.Fatalf("unexpected stderr: got %q, want %q", message, tt.wantStdErr)
}
}
})
}
}
Loading