Skip to content

Commit 88eb7a4

Browse files
authored
Merge pull request #499 from docker/feat/pass/cli
feat(pass): add --metadata flag to pass set
2 parents f4adbca + b9fc271 commit 88eb7a4

File tree

5 files changed

+143
-24
lines changed

5 files changed

+143
-24
lines changed

plugins/pass/command_test.go

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ func Test_rootCommand(t *testing.T) {
5656
require.NoError(t, err)
5757
impl, ok := s.(*pass.PassValue)
5858
require.True(t, ok)
59-
assert.Equal(t, "bar=bar=bar", string(impl.Value))
59+
v, err := impl.Marshal()
60+
require.NoError(t, err)
61+
assert.Equal(t, "bar=bar=bar", string(v))
6062
})
6163
t.Run("from STDIN", func(t *testing.T) {
6264
mock := teststore.NewMockStore()
@@ -67,7 +69,56 @@ func Test_rootCommand(t *testing.T) {
6769
require.NoError(t, err)
6870
impl, ok := s.(*pass.PassValue)
6971
require.True(t, ok)
70-
assert.Equal(t, "my\nmultiline\nvalue", string(impl.Value))
72+
v, err := impl.Marshal()
73+
require.NoError(t, err)
74+
assert.Equal(t, "my\nmultiline\nvalue", string(v))
75+
})
76+
t.Run("with --metadata flag", func(t *testing.T) {
77+
mock := teststore.NewMockStore()
78+
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "set", "foo=bar", "--metadata", "name=bob", "--metadata", "expiry=2027-03-01")
79+
assert.NoError(t, err)
80+
assert.Empty(t, out)
81+
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
82+
require.NoError(t, err)
83+
impl, ok := s.(*pass.PassValue)
84+
require.True(t, ok)
85+
v, err := impl.Marshal()
86+
require.NoError(t, err)
87+
assert.Equal(t, "bar", string(v))
88+
assert.Equal(t, map[string]string{"name": "bob", "expiry": "2027-03-01"}, impl.Metadata())
89+
})
90+
t.Run("from STDIN JSON with value and metadata", func(t *testing.T) {
91+
mock := teststore.NewMockStore()
92+
out, err := executeCommandWithStdin(Root(t.Context(), mock, mockInfo), `{"secret":"bar","metadata":{"name":"bob"}}`, "set", "foo")
93+
assert.NoError(t, err)
94+
assert.Empty(t, out)
95+
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
96+
require.NoError(t, err)
97+
impl, ok := s.(*pass.PassValue)
98+
require.True(t, ok)
99+
v, err := impl.Marshal()
100+
require.NoError(t, err)
101+
assert.Equal(t, "bar", string(v))
102+
assert.Equal(t, map[string]string{"name": "bob"}, impl.Metadata())
103+
})
104+
t.Run("from STDIN JSON merged with --metadata flag wins on collision", func(t *testing.T) {
105+
mock := teststore.NewMockStore()
106+
out, err := executeCommandWithStdin(Root(t.Context(), mock, mockInfo), `{"secret":"bar","metadata":{"name":"bob","extra":"thing"}}`, "set", "foo", "--metadata", "name=alice")
107+
assert.NoError(t, err)
108+
assert.Empty(t, out)
109+
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
110+
require.NoError(t, err)
111+
impl, ok := s.(*pass.PassValue)
112+
require.True(t, ok)
113+
v, err := impl.Marshal()
114+
require.NoError(t, err)
115+
assert.Equal(t, "bar", string(v))
116+
assert.Equal(t, map[string]string{"name": "alice", "extra": "thing"}, impl.Metadata())
117+
})
118+
t.Run("invalid --metadata flag (no =)", func(t *testing.T) {
119+
mock := teststore.NewMockStore()
120+
_, err := executeCommand(Root(t.Context(), mock, mockInfo), "set", "foo=bar", "--metadata", "invalid")
121+
assert.ErrorContains(t, err, "invalid metadata pair (expected key=value): invalid")
71122
})
72123
t.Run("store error", func(t *testing.T) {
73124
errSave := errors.New("save error")
@@ -88,8 +139,8 @@ func Test_rootCommand(t *testing.T) {
88139
t.Run("list", func(t *testing.T) {
89140
t.Run("ok", func(t *testing.T) {
90141
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
91-
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
92-
store.MustParseID("baz"): &pass.PassValue{Value: []byte("0")},
142+
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
143+
store.MustParseID("baz"): pass.NewPassValue([]byte("0")),
93144
}))
94145
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "list")
95146
assert.NoError(t, err)
@@ -106,8 +157,8 @@ func Test_rootCommand(t *testing.T) {
106157
t.Run("rm", func(t *testing.T) {
107158
t.Run("ok (two secrets)", func(t *testing.T) {
108159
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
109-
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
110-
store.MustParseID("baz"): &pass.PassValue{Value: []byte("0")},
160+
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
161+
store.MustParseID("baz"): pass.NewPassValue([]byte("0")),
111162
}))
112163
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "rm", "foo", "baz")
113164
assert.NoError(t, err)
@@ -118,8 +169,8 @@ func Test_rootCommand(t *testing.T) {
118169
})
119170
t.Run("--all", func(t *testing.T) {
120171
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
121-
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
122-
store.MustParseID("baz"): &pass.PassValue{Value: []byte("0")},
172+
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
173+
store.MustParseID("baz"): pass.NewPassValue([]byte("0")),
123174
}))
124175
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "rm", "--all")
125176
assert.NoError(t, err)
@@ -158,7 +209,7 @@ func Test_rootCommand(t *testing.T) {
158209
t.Run("get", func(t *testing.T) {
159210
t.Run("ok", func(t *testing.T) {
160211
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
161-
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
212+
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
162213
}))
163214
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "get", "foo")
164215
assert.NoError(t, err)
@@ -200,7 +251,7 @@ func Test_rootCommandTelemetry(t *testing.T) {
200251
t.Run(tc.name, func(t *testing.T) {
201252
spanRecorder, metricReader := testhelper.SetupTelemetry(t)
202253
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
203-
store.MustParseID("baz"): &pass.PassValue{Value: []byte("bar")},
254+
store.MustParseID("baz"): pass.NewPassValue([]byte("bar")),
204255
}))
205256
_, err := executeCommand(Root(t.Context(), mock, mockInfo), tc.args...)
206257
assert.NoError(t, err)

plugins/pass/commands/set.go

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ package commands
1616

1717
import (
1818
"context"
19+
"encoding/json"
1920
"fmt"
2021
"io"
22+
"maps"
2123
"strings"
2224

2325
"github.com/spf13/cobra"
@@ -34,9 +36,25 @@ docker pass set POSTGRES_PASSWORD=my-secret-password
3436
# Or pass the secret via STDIN:
3537
echo my-secret-password > pwd.txt
3638
cat pwd.txt | docker pass set POSTGRES_PASSWORD
39+
40+
# Set a secret with metadata:
41+
docker pass set POSTGRES_PASSWORD=my-secret-password --metadata owner=alice --metadata expiry=2027-03-01
42+
43+
# Or pass a JSON payload with secret and metadata via STDIN:
44+
echo '{"secret":"my-secret-password","metadata":{"owner":"alice"}}' | docker pass set POSTGRES_PASSWORD
3745
`
3846

47+
type setOpts struct {
48+
metadata []string // raw "key=value" strings from --metadata flag
49+
}
50+
51+
type stdinPayload struct {
52+
Secret string `json:"secret"`
53+
Metadata map[string]string `json:"metadata,omitempty"`
54+
}
55+
3956
func SetCommand(kc store.Store) *cobra.Command {
57+
opts := setOpts{}
4058
cmd := &cobra.Command{
4159
Use: "set id[=value]",
4260
Aliases: []string{"store", "save"},
@@ -62,12 +80,50 @@ func SetCommand(kc store.Store) *cobra.Command {
6280
if err != nil {
6381
return err
6482
}
65-
return kc.Save(cmd.Context(), id, &pass.PassValue{Value: []byte(s.val)})
83+
84+
flagMeta, err := parseMetadataFlags(opts.metadata)
85+
if err != nil {
86+
return err
87+
}
88+
89+
// Merge: start with STDIN JSON metadata, override with flag metadata
90+
merged := maps.Clone(s.metadata)
91+
for k, v := range flagMeta {
92+
if merged == nil {
93+
merged = make(map[string]string)
94+
}
95+
merged[k] = v
96+
}
97+
98+
pv := &pass.PassValue{}
99+
if err := pv.Unmarshal([]byte(s.val)); err != nil {
100+
return err
101+
}
102+
if len(merged) > 0 {
103+
if err := pv.SetMetadata(merged); err != nil {
104+
return err
105+
}
106+
}
107+
return kc.Save(cmd.Context(), id, pv)
66108
},
67109
}
110+
flags := cmd.Flags()
111+
flags.StringArrayVar(&opts.metadata, "metadata", nil, "Non-sensitive key=value metadata (repeatable)")
68112
return cmd
69113
}
70114

115+
func parseMetadataFlags(raw []string) (map[string]string, error) {
116+
m := make(map[string]string, len(raw))
117+
for _, kv := range raw {
118+
k, v, ok := strings.Cut(kv, "=")
119+
if !ok {
120+
return nil, fmt.Errorf("invalid metadata pair (expected key=value): %s", kv)
121+
}
122+
m[k] = v
123+
}
124+
return m, nil
125+
}
126+
71127
func isNotImplicitReadFromStdinSyntax(args []string) bool {
72128
return strings.Contains(args[0], "=") || len(args) > 1
73129
}
@@ -79,15 +135,17 @@ func secretMappingFromSTDIN(ctx context.Context, reader io.Reader, id string) (*
79135
}
80136
defer clear(data)
81137

82-
return &secret{
83-
id: id,
84-
val: string(data),
85-
}, nil
138+
var payload stdinPayload
139+
if err := json.Unmarshal(data, &payload); err == nil && payload.Secret != "" {
140+
return &secret{id: id, val: payload.Secret, metadata: payload.Metadata}, nil
141+
}
142+
return &secret{id: id, val: string(data)}, nil
86143
}
87144

88145
type secret struct {
89-
id string
90-
val string
146+
id string
147+
val string
148+
metadata map[string]string
91149
}
92150

93151
func parseArg(arg string) (*secret, error) {

plugins/pass/plugin.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,13 @@ func unpackValue(id store.ID, secret store.Secret) (*plugin.Envelope, error) {
6060
if !ok {
6161
return nil, errUnknownSecretType
6262
}
63+
value, err := impl.Marshal()
64+
if err != nil {
65+
return nil, err
66+
}
6367
return &plugin.Envelope{
6468
ID: id,
65-
Value: impl.Value,
69+
Value: value,
6670
}, nil
6771
}
6872

plugins/pass/plugin_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func Test_passPlugin(t *testing.T) {
3434
t.Parallel()
3535
t.Run("ok", func(t *testing.T) {
3636
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
37-
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
37+
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
3838
}))
3939
p := &passPlugin{kc: mock, logger: testhelper.TestLogger(t)}
4040
e, err := p.GetSecrets(t.Context(), secrets.MustParsePattern("foo"))

plugins/pass/store/store.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,29 @@ import (
2424
var _ store.Secret = &PassValue{}
2525

2626
type PassValue struct {
27-
Value []byte `json:"value"`
27+
value []byte
28+
metadata map[string]string // not exported; populated via SetMetadata
29+
}
30+
31+
func NewPassValue(value []byte) *PassValue {
32+
return &PassValue{value: value}
2833
}
2934

3035
func (m *PassValue) Marshal() ([]byte, error) {
31-
return m.Value, nil
36+
return m.value, nil
3237
}
3338

3439
func (m *PassValue) Unmarshal(data []byte) error {
35-
m.Value = data
40+
m.value = data
3641
return nil
3742
}
3843

3944
func (m *PassValue) Metadata() map[string]string {
40-
return nil
45+
return m.metadata
4146
}
4247

43-
func (m *PassValue) SetMetadata(map[string]string) error {
48+
func (m *PassValue) SetMetadata(md map[string]string) error {
49+
m.metadata = md
4450
return nil
4551
}
4652

0 commit comments

Comments
 (0)