Skip to content

Commit

Permalink
CSI: use HTTP headers for passing CSI secrets
Browse files Browse the repository at this point in the history
  • Loading branch information
tgross committed Feb 28, 2022
1 parent 0b3ba5c commit 3b4d216
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 39 deletions.
6 changes: 6 additions & 0 deletions .changelog/12144.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
```release-note:improvement
api: CSI secrets for list and delete snapshots are now passed in HTTP headers
```

```release-note:improvement
cli: CSI secrets argument for `volume snapshot list` has been made consistent with `volume snapshot delete`
14 changes: 14 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ type QueryOptions struct {
// Set HTTP parameters on the query.
Params map[string]string

// Set HTTP headers on the query.
Headers map[string]string

// AuthToken is the secret ID of an ACL token
AuthToken string

Expand Down Expand Up @@ -101,6 +104,9 @@ type WriteOptions struct {
// AuthToken is the secret ID of an ACL token
AuthToken string

// Set HTTP headers on the query.
Headers map[string]string

// ctx is an optional context pass through to the underlying HTTP
// request layer. Use Context() and WithContext() to manage this.
ctx context.Context
Expand Down Expand Up @@ -606,6 +612,10 @@ func (r *request) setQueryOptions(q *QueryOptions) {
r.params.Set(k, v)
}
r.ctx = q.Context()

for k, v := range q.Headers {
r.header.Set(k, v)
}
}

// durToMsec converts a duration to a millisecond specified string
Expand All @@ -632,6 +642,10 @@ func (r *request) setWriteOptions(q *WriteOptions) {
r.params.Set("idempotency_token", q.IdempotencyToken)
}
r.ctx = q.Context()

for k, v := range q.Headers {
r.header.Set(k, v)
}
}

// toHTTP converts the request to an HTTP request
Expand Down
56 changes: 50 additions & 6 deletions api/csi.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/url"
"sort"
"strings"
"time"
)

Expand Down Expand Up @@ -129,13 +130,37 @@ func (v *CSIVolumes) DeleteSnapshot(snap *CSISnapshot, w *WriteOptions) error {
qp := url.Values{}
qp.Set("snapshot_id", snap.ID)
qp.Set("plugin_id", snap.PluginID)
for k, v := range snap.Secrets {
qp.Set("secret", fmt.Sprintf("%v=%v", k, v))
}
w.SetHeadersFromCSISecrets(snap.Secrets)
_, err := v.client.delete("/v1/volumes/snapshot?"+qp.Encode(), nil, w)
return err
}

// ListSnapshotsOpts lists external storage volume snapshots.
func (v *CSIVolumes) ListSnapshotsOpts(req *CSISnapshotListRequest) (*CSISnapshotListResponse, *QueryMeta, error) {
var resp *CSISnapshotListResponse

qp := url.Values{}
if req.PluginID != "" {
qp.Set("plugin_id", req.PluginID)
}
if req.NextToken != "" {
qp.Set("next_token", req.NextToken)
}
if req.PerPage != 0 {
qp.Set("per_page", fmt.Sprint(req.PerPage))
}
req.QueryOptions.SetHeadersFromCSISecrets(req.Secrets)

qm, err := v.client.query("/v1/volumes/snapshot?"+qp.Encode(), &resp, &req.QueryOptions)
if err != nil {
return nil, nil, err
}

sort.Sort(CSISnapshotSort(resp.Snapshots))
return resp, qm, nil
}

// DEPRECATED: will be removed in Nomad 1.4.0
// ListSnapshots lists external storage volume snapshots.
func (v *CSIVolumes) ListSnapshots(pluginID string, secrets string, q *QueryOptions) (*CSISnapshotListResponse, *QueryMeta, error) {
var resp *CSISnapshotListResponse
Expand All @@ -150,9 +175,6 @@ func (v *CSIVolumes) ListSnapshots(pluginID string, secrets string, q *QueryOpti
if q.PerPage != 0 {
qp.Set("per_page", fmt.Sprint(q.PerPage))
}
if secrets != "" {
qp.Set("secrets", secrets)
}

qm, err := v.client.query("/v1/volumes/snapshot?"+qp.Encode(), &resp, q)
if err != nil {
Expand Down Expand Up @@ -206,6 +228,28 @@ type CSIMountOptions struct {
// API or in Nomad's logs.
type CSISecrets map[string]string

func (q *QueryOptions) SetHeadersFromCSISecrets(secrets CSISecrets) {
pairs := []string{}
for k, v := range secrets {
pairs = append(pairs, fmt.Sprintf("%v=%v", k, v))
}
if q.Headers == nil {
q.Headers = map[string]string{}
}
q.Headers["X-Nomad-CSI-Secrets"] = strings.Join(pairs, ",")
}

func (q *WriteOptions) SetHeadersFromCSISecrets(secrets CSISecrets) {
pairs := []string{}
for k, v := range secrets {
pairs = append(pairs, fmt.Sprintf("%v=%v", k, v))
}
if q.Headers == nil {
q.Headers = map[string]string{}
}
q.Headers["X-Nomad-CSI-Secrets"] = strings.Join(pairs, ",")
}

// CSIVolume is used for serialization, see also nomad/structs/csi.go
type CSIVolume struct {
ID string
Expand Down
48 changes: 28 additions & 20 deletions command/agent/csi_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,9 @@ func (s *HTTPServer) csiSnapshotDelete(resp http.ResponseWriter, req *http.Reque
query := req.URL.Query()
snap.PluginID = query.Get("plugin_id")
snap.ID = query.Get("snapshot_id")
secrets := query["secret"]
for _, raw := range secrets {
secret := strings.Split(raw, "=")
if len(secret) == 2 {
snap.Secrets[secret[0]] = secret[1]
}
}

secrets := parseCSISecrets(req)
snap.Secrets = secrets

args.Snapshots = []*structs.CSISnapshot{snap}

Expand All @@ -333,19 +329,9 @@ func (s *HTTPServer) csiSnapshotList(resp http.ResponseWriter, req *http.Request

query := req.URL.Query()
args.PluginID = query.Get("plugin_id")
querySecrets := query["secrets"]

// Parse comma separated secrets only when provided
if len(querySecrets) >= 1 {
secrets := strings.Split(querySecrets[0], ",")
args.Secrets = make(structs.CSISecrets)
for _, raw := range secrets {
secret := strings.Split(raw, "=")
if len(secret) == 2 {
args.Secrets[secret[0]] = secret[1]
}
}
}

secrets := parseCSISecrets(req)
args.Secrets = secrets

var out structs.CSISnapshotListResponse
if err := s.agent.RPC("CSIVolume.ListSnapshots", &args, &out); err != nil {
Expand Down Expand Up @@ -420,6 +406,28 @@ func (s *HTTPServer) CSIPluginSpecificRequest(resp http.ResponseWriter, req *htt
return structsCSIPluginToApi(out.Plugin), nil
}

// parseCSISecrets extracts a map of k/v pairs from the CSI secrets
// header. Silently ignores invalid secrets
func parseCSISecrets(req *http.Request) structs.CSISecrets {
secretsHeader := req.Header.Get("X-Nomad-CSI-Secrets")
if secretsHeader == "" {
return nil
}

secrets := map[string]string{}
secretkvs := strings.Split(secretsHeader, ",")
for _, secretkv := range secretkvs {
kv := strings.Split(secretkv, "=")
if len(kv) == 2 {
secrets[kv[0]] = kv[1]
}
}
if len(secrets) == 0 {
return nil
}
return structs.CSISecrets(secrets)
}

// structsCSIPluginToApi converts CSIPlugin, setting Expected the count of known plugin
// instances
func structsCSIPluginToApi(plug *structs.CSIPlugin) *api.CSIPlugin {
Expand Down
23 changes: 23 additions & 0 deletions command/agent/csi_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ func TestHTTP_CSIEndpointPlugin(t *testing.T) {
})
}

func TestHTTP_CSIParseSecrets(t *testing.T) {
t.Parallel()
testCases := []struct {
val string
expect structs.CSISecrets
}{
{"", nil},
{"one", nil},
{"one,two", nil},
{"one,two=value_two",
structs.CSISecrets(map[string]string{"two": "value_two"})},
{"one=value_one,one=overwrite",
structs.CSISecrets(map[string]string{"one": "overwrite"})},
{"one=value_one,two=value_two",
structs.CSISecrets(map[string]string{"one": "value_one", "two": "value_two"})},
}
for _, tc := range testCases {
req, _ := http.NewRequest("GET", "/v1/plugin/csi/foo", nil)
req.Header.Add("X-Nomad-CSI-Secrets", tc.val)
require.Equal(t, tc.expect, parseCSISecrets(req), tc.val)
}
}

func TestHTTP_CSIEndpointUtils(t *testing.T) {
secrets := structsCSISecretsToApi(structs.CSISecrets{
"foo": "bar",
Expand Down
2 changes: 1 addition & 1 deletion command/volume_snapshot_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ General Options:
Snapshot Options:
-secret
Secrets to pass to the plugin to create the snapshot. Accepts multiple
Secrets to pass to the plugin to delete the snapshot. Accepts multiple
flags in the form -secret key=value
`
Expand Down
29 changes: 22 additions & 7 deletions command/volume_snapshot_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
humanize "github.com/dustin/go-humanize"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/api/contexts"
flaghelper "github.com/hashicorp/nomad/helper/flags"
"github.com/pkg/errors"
"github.com/posener/complete"
)
Expand Down Expand Up @@ -36,7 +37,9 @@ List Options:
-plugin: Display only snapshots managed by a particular plugin. By default
this command will query all plugins for their snapshots.
-secrets: A set of key/value secrets to be used when listing snapshots.
-secret
Secrets to pass to the plugin to list snapshots. Accepts multiple
flags in the form -secret key=value
`
return strings.TrimSpace(helpText)
}
Expand Down Expand Up @@ -70,13 +73,13 @@ func (c *VolumeSnapshotListCommand) Name() string { return "volume snapshot list
func (c *VolumeSnapshotListCommand) Run(args []string) int {
var pluginID string
var verbose bool
var secrets string
var secretsArgs flaghelper.StringFlag

flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&pluginID, "plugin", "", "")
flags.BoolVar(&verbose, "verbose", false, "")
flags.StringVar(&secrets, "secrets", "", "")
flags.Var(&secretsArgs, "secret", "secrets for snapshot, ex. -secret key=value")

if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing arguments %s", err))
Expand Down Expand Up @@ -122,10 +125,22 @@ func (c *VolumeSnapshotListCommand) Run(args []string) int {
pluginID = plugs[0].ID
}

q := &api.QueryOptions{PerPage: 30} // TODO: tune page size
secrets := api.CSISecrets{}
for _, kv := range secretsArgs {
s := strings.Split(kv, "=")
if len(s) == 2 {
secrets[s[0]] = s[1]
}
}

req := &api.CSISnapshotListRequest{
PluginID: pluginID,
Secrets: secrets,
QueryOptions: api.QueryOptions{PerPage: 30},
}

for {
resp, _, err := client.CSIVolumes().ListSnapshots(pluginID, secrets, q)
resp, _, err := client.CSIVolumes().ListSnapshotsOpts(req)
if err != nil && !errors.Is(err, io.EOF) {
c.Ui.Error(fmt.Sprintf(
"Error querying CSI external snapshots for plugin %q: %s", pluginID, err))
Expand All @@ -138,8 +153,8 @@ func (c *VolumeSnapshotListCommand) Run(args []string) int {
}

c.Ui.Output(csiFormatSnapshots(resp.Snapshots, verbose))
q.NextToken = resp.NextToken
if q.NextToken == "" {
req.NextToken = resp.NextToken
if req.NextToken == "" {
break
}
// we can't know the shape of arbitrarily-sized lists of snapshots,
Expand Down
5 changes: 5 additions & 0 deletions website/content/docs/commands/volume/snapshot-delete.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ volume` and `plugin:read` capabilities.

@include 'general_options.mdx'

## Snapshot Delete Options

- `-secret`: Secrets to pass to the plugin to delete the
snapshot. Accepts multiple flags in the form `-secret key=value`

## Examples

Delete a volume snapshot:
Expand Down
10 changes: 5 additions & 5 deletions website/content/docs/commands/volume/snapshot-list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ Nomad.

@include 'general_options.mdx'

## List Options
## Snapshot List Options

- `-plugin`: Display only snapshots managed by a particular [CSI
plugin][csi_plugin]. By default the `snapshot list` command will query all
plugins for their snapshots. This flag accepts a plugin ID or prefix. If
there is an exact match based on the provided plugin, then that specific
plugin will be queried. Otherwise, a list of matching plugins will be
displayed.
- `-secrets`: A list of comma separated secret key/value pairs to be passed
to the CSI driver.
- `-secret`: Secrets to pass to the plugin to delete the
snapshot. Accepts multiple flags in the form `-secret key=value`

When ACLs are enabled, this command requires a token with the
`csi-list-volumes` capability for the plugin's namespace.
Expand All @@ -54,12 +54,12 @@ snap-67890 vol-fedcba 50GiB 2021-01-04T15:45:00Z true

List volume snapshots with two secret key/value pairs:
```shell-session
$ nomad volume snapshot list -secrets key1=value1,key2=val2
$ nomad volume snapshot list -secret key1=value1 -secret key2=val2
Snapshot ID External ID Size Creation Time Ready?
snap-12345 vol-abcdef 50GiB 2021-01-03T12:15:02Z true
```

[csi]: https://github.com/container-storage-interface/spec
[csi_plugin]: /docs/job-specification/csi_plugin
[registered]: /docs/commands/volume/register
[csi_plugins_internals]: /docs/internals/plugins/csi#csi-plugins
[csi_plugins_internals]: /docs/internals/plugins/csi#csi-plugins

0 comments on commit 3b4d216

Please sign in to comment.