Skip to content
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
78 changes: 78 additions & 0 deletions internal/pkg/flags/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package flags

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

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

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

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

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
}
124 changes: 124 additions & 0 deletions internal/pkg/flags/secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package flags

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

"github.com/spf13/cobra"

"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.Fs = fstest.MapFS{
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)
}
}
})
}
}