Skip to content

Commit 0958388

Browse files
committed
feat(secret flag) add reusable secret flag implementation, update guide
Had to split implementation into two parts: `Set()` and `SecretFlagToSP`. Set is only called when an argument is specified on the command line. So moving the `PromptForPassword` logic into `Set` does not work. I'm not sure if the current solution with a specialized `*ToStringPointer` func is the way to go. So I've only refactored `obs-credentials add` as an example.
1 parent 72658b0 commit 0958388

File tree

5 files changed

+224
-8
lines changed

5 files changed

+224
-8
lines changed

.github/docs/contribution-guide/cmd.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,17 @@ import (
2222

2323
// Define consts for command flags
2424
const (
25-
someArg = "MY_ARG"
26-
someFlag = "my-flag"
25+
someArg = "MY_ARG"
26+
someFlag = "my-flag"
27+
secretFlag = "secret"
2728
)
2829

2930
// Struct to model user input (arguments and/or flags)
3031
type inputModel struct {
3132
*globalflags.GlobalFlagModel
3233
MyArg string
3334
MyFlag *string
35+
Secret *string
3436
}
3537

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

8789
// Configure command flags (type, default value, and description)
88-
func configureFlags(cmd *cobra.Command) {
90+
func configureFlags(cmd *cobra.Command, params *types.CmdParams) {
8991
cmd.Flags().StringP(someFlag, "shorthand", "defaultValue", "My flag description")
92+
secret := flags.SecretFlag(secretFlag, params)
93+
cmd.Flags().Var(secret, secretFlag, secret.Usage())
9094
}
9195

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

107112
// Write the input model to the debug logs

CONTRIBUTION.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ For prints that are specific to a certain log level, you can use the methods def
7676

7777
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_".
7878

79+
#### Handling secrets
80+
81+
If your command needs secrets as input, please make sure to use `flags.SecretFlag()` and `flags.SecretFlagToStringPointer()`.
82+
These functions implement reading from stdin or a file.
83+
84+
They also support reading the secret value as a command line argument (deprecated, marked for removal in Oct 2026).
85+
7986
### Onboarding a new STACKIT service
8087

8188
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:

internal/cmd/beta/alb/observability-credentials/add/add.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
3939
Example: examples.Build(
4040
examples.NewExample(
4141
`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`,
42-
"$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy"),
42+
"$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --displayname yyy"),
4343
),
4444
RunE: func(cmd *cobra.Command, args []string) error {
4545
ctx := context.Background()
@@ -71,14 +71,16 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
7171
return outputResult(params.Printer, model.OutputFormat, resp)
7272
},
7373
}
74-
configureFlags(cmd)
74+
configureFlags(cmd, params)
7575
return cmd
7676
}
7777

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

8385
cobra.CheckErr(flags.MarkFlagsRequired(cmd, usernameFlag, displaynameFlag))
8486
}
@@ -90,7 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel,
9092
GlobalFlagModel: globalFlags,
9193
Username: flags.FlagToStringPointer(p, cmd, usernameFlag),
9294
Displayname: flags.FlagToStringPointer(p, cmd, displaynameFlag),
93-
Password: flags.FlagToStringPointer(p, cmd, passwordFlag),
95+
Password: flags.SecretFlagToStringPointer(p, cmd, passwordFlag),
9496
}
9597

9698
p.DebugInputModel(model)

internal/pkg/flags/secret.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package flags
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/spf13/pflag"
10+
"github.com/stackitcloud/stackit-cli/internal/pkg/types"
11+
12+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
13+
)
14+
15+
type secretFlag struct {
16+
printer *print.Printer
17+
fs fs.FS
18+
value string
19+
name string
20+
}
21+
22+
23+
func SecretFlag(name string, params *types.CmdParams) *secretFlag {
24+
f := &secretFlag{
25+
printer: params.Printer,
26+
fs: params.Fs,
27+
name: name,
28+
}
29+
return f
30+
}
31+
32+
var _ pflag.Value = &secretFlag{}
33+
34+
func (f *secretFlag) String() string {
35+
return f.value
36+
}
37+
38+
func (f *secretFlag) Set(value string) error {
39+
if strings.HasPrefix(value, "@") {
40+
path := strings.Trim(value[1:], `"'`)
41+
bytes, err := fs.ReadFile(f.fs, path)
42+
if err != nil {
43+
return fmt.Errorf("reading secret %s: %w", f.name, err)
44+
}
45+
f.value = string(bytes)
46+
return nil
47+
}
48+
f.printer.Warn("Passing a secret value on the command line is insecure and deprecated. This usage will stop working October 2026.\n")
49+
f.value = value
50+
return nil
51+
}
52+
53+
func (f *secretFlag) Type() string {
54+
return "string"
55+
}
56+
57+
func (f *secretFlag) Usage() string {
58+
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)
59+
}
60+
61+
func SecretFlagToStringPointer(p *print.Printer, cmd *cobra.Command, flag string) *string {
62+
value, err := cmd.Flags().GetString(flag)
63+
if err != nil {
64+
p.Debug(print.ErrorLevel, "convert secret flag to string pointer: %v", err)
65+
return nil
66+
}
67+
if value == "" {
68+
input, err := p.PromptForPassword(fmt.Sprintf("enter %s: ", flag))
69+
if err != nil {
70+
p.Debug(print.ErrorLevel, "convert secret flag %q to string pointer: %v", flag, err)
71+
return nil
72+
}
73+
return &input
74+
}
75+
if cmd.Flag(flag).Changed {
76+
return &value
77+
}
78+
return nil
79+
}

internal/pkg/flags/secret_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package flags
2+
3+
import (
4+
"io"
5+
"testing"
6+
"testing/fstest"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/testparams"
10+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
11+
)
12+
13+
type testFile struct {
14+
path, content string
15+
}
16+
17+
func TestSecretFlag(t *testing.T) {
18+
t.Parallel()
19+
tests := []struct {
20+
name string
21+
value string
22+
want *string
23+
file *testFile
24+
stdin string
25+
wantErr bool
26+
wantStdErr string
27+
}{
28+
{
29+
name: "no value: prompts",
30+
value: "",
31+
want: utils.Ptr("from stdin"),
32+
stdin: "from stdin",
33+
},
34+
{
35+
name: "a value: prints deprecation",
36+
value: "a value",
37+
want: utils.Ptr("a value"),
38+
wantStdErr: "Warning: Passing a secret value on the command line is insecure and deprecated. This usage will stop working October 2026.\n",
39+
},
40+
{
41+
name: "from an existing file",
42+
value: "@some-file.txt",
43+
want: utils.Ptr("from file"),
44+
file: &testFile{
45+
path: "some-file.txt",
46+
content: "from file",
47+
},
48+
},
49+
{
50+
name: "from a non-existing file",
51+
value: "@some-file-with-typo.txt",
52+
wantErr: true,
53+
file: &testFile{
54+
path: "some-file.txt",
55+
content: "from file",
56+
},
57+
},
58+
{
59+
name: "from an existing double-quoted file",
60+
value: `@"some-file.txt"`,
61+
want: utils.Ptr("from file"),
62+
file: &testFile{
63+
path: "some-file.txt",
64+
content: "from file",
65+
},
66+
},
67+
{
68+
name: "from an existing single-quoted file",
69+
value: "@'some-file.txt'",
70+
want: utils.Ptr("from file"),
71+
file: &testFile{
72+
path: "some-file.txt",
73+
content: "from file",
74+
},
75+
},
76+
}
77+
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
t.Parallel()
81+
params := testparams.NewTestParams()
82+
if tt.file != nil {
83+
params.CmdParams.Fs = fstest.MapFS{
84+
tt.file.path: &fstest.MapFile{
85+
Data: []byte(tt.file.content),
86+
},
87+
}
88+
}
89+
flag := SecretFlag("test", params.CmdParams)
90+
cmd := cobra.Command{}
91+
cmd.Flags().Var(flag, "test", flag.Usage())
92+
if tt.stdin != "" {
93+
params.In.WriteString(tt.stdin)
94+
params.In.WriteString("\n")
95+
}
96+
97+
if tt.value != "" { // emulate pflag only calling set when flag is specified on the command line
98+
err := cmd.Flags().Set("test", tt.value)
99+
if err != nil && !tt.wantErr {
100+
t.Fatalf("unexpected error: %v", err)
101+
}
102+
if err == nil && tt.wantErr {
103+
t.Fatalf("expected error, got none")
104+
}
105+
}
106+
107+
got := SecretFlagToStringPointer(params.Printer, &cmd, "test")
108+
109+
if got != tt.want && *got != *tt.want {
110+
t.Fatalf("unexpected value: got %q, want %q", *got, *tt.want)
111+
}
112+
if tt.wantStdErr != "" {
113+
message, err := params.Err.ReadString('\n')
114+
if err != nil && err != io.EOF {
115+
t.Fatalf("reading stderr: %v", err)
116+
}
117+
if message != tt.wantStdErr {
118+
t.Fatalf("unexpected stderr: got %q, want %q", message, tt.wantStdErr)
119+
}
120+
}
121+
})
122+
}
123+
}

0 commit comments

Comments
 (0)