From 73a193f6d94192f578587171a21daae9f86761ee Mon Sep 17 00:00:00 2001 From: Piotr Kazmierczak <470696+pkazmierczak@users.noreply.github.com> Date: Thu, 27 Feb 2025 17:04:48 +0100 Subject: [PATCH] stateful deployments: task group host volume claims CLI (#25116) CLI for interacting with task group host volume claims. --- .../task_group_host_volume_claim_endpoint.go | 2 +- command/commands.go | 15 ++ command/meta.go | 24 +++ command/setup_consul.go | 24 --- command/setup_vault.go | 24 --- command/volume_claim.go | 46 +++++ command/volume_claim_delete.go | 124 ++++++++++++++ command/volume_claim_delete_test.go | 107 ++++++++++++ command/volume_claim_list.go | 160 ++++++++++++++++++ command/volume_claim_list_test.go | 130 ++++++++++++++ nomad/fsm.go | 4 +- 11 files changed, 609 insertions(+), 51 deletions(-) create mode 100644 command/volume_claim.go create mode 100644 command/volume_claim_delete.go create mode 100644 command/volume_claim_delete_test.go create mode 100644 command/volume_claim_list.go create mode 100644 command/volume_claim_list_test.go diff --git a/command/agent/task_group_host_volume_claim_endpoint.go b/command/agent/task_group_host_volume_claim_endpoint.go index 0fb92b1f5..ebc48777d 100644 --- a/command/agent/task_group_host_volume_claim_endpoint.go +++ b/command/agent/task_group_host_volume_claim_endpoint.go @@ -13,7 +13,7 @@ import ( func (s *HTTPServer) TaskGroupHostVolumeClaimRequest(resp http.ResponseWriter, req *http.Request) (any, error) { // Tokenize the suffix of the path to get the volume id, tolerating a // present or missing trailing slash - reqSuffix := strings.TrimPrefix(req.URL.Path, "/v1/volume/claim/") + reqSuffix := strings.TrimPrefix(req.URL.Path, "/v1/volumes/claim/") tokens := strings.FieldsFunc(reqSuffix, func(c rune) bool { return c == '/' }) if len(tokens) == 0 { diff --git a/command/commands.go b/command/commands.go index a29e97ea7..9b078de20 100644 --- a/command/commands.go +++ b/command/commands.go @@ -1269,6 +1269,21 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "volume claim": func() (cli.Command, error) { + return &VolumeClaimCommand{ + Meta: meta, + }, nil + }, + "volume claim list": func() (cli.Command, error) { + return &VolumeClaimListCommand{ + Meta: meta, + }, nil + }, + "volume claim delete": func() (cli.Command, error) { + return &VolumeClaimDeleteCommand{ + Meta: meta, + }, nil + }, } deprecated := map[string]cli.CommandFactory{ diff --git a/command/meta.go b/command/meta.go index da641896a..85b058e74 100644 --- a/command/meta.go +++ b/command/meta.go @@ -120,6 +120,30 @@ func (m *Meta) AutocompleteFlags(fs FlagSetFlags) complete.Flags { } } +// askQuestion asks question to user until they provide a valid response. +func (m *Meta) askQuestion(question string) bool { + for { + answer, err := m.Ui.Ask(m.Colorize().Color(fmt.Sprintf("[?] %s", question))) + if err != nil { + if err.Error() != "interrupted" { + m.Ui.Output(err.Error()) + os.Exit(1) + } + os.Exit(0) + } + + switch strings.TrimSpace(strings.ToLower(answer)) { + case "", "y", "yes": + return true + case "n", "no": + return false + default: + m.Ui.Output(fmt.Sprintf(`%q is not a valid response, please answer "yes" or "no".`, answer)) + continue + } + } +} + // ApiClientFactory is the signature of a API client factory type ApiClientFactory func() (*api.Client, error) diff --git a/command/setup_consul.go b/command/setup_consul.go index 258c7011d..2b043ecf6 100644 --- a/command/setup_consul.go +++ b/command/setup_consul.go @@ -583,30 +583,6 @@ func (s *SetupConsulCommand) createPolicy() error { return nil } -// askQuestion asks question to user until they provide a valid response. -func (s *SetupConsulCommand) askQuestion(question string) bool { - for { - answer, err := s.Ui.Ask(s.Colorize().Color(fmt.Sprintf("[?] %s", question))) - if err != nil { - if err.Error() != "interrupted" { - s.Ui.Output(err.Error()) - os.Exit(1) - } - os.Exit(0) - } - - switch strings.TrimSpace(strings.ToLower(answer)) { - case "", "y", "yes": - return true - case "n", "no": - return false - default: - s.Ui.Output(fmt.Sprintf(`%q is not a valid response, please answer "yes" or "no".`, answer)) - continue - } - } -} - func (s *SetupConsulCommand) handleNo() { s.Ui.Warn(` By answering "no" to any of these questions, you are risking an incorrect Consul diff --git a/command/setup_vault.go b/command/setup_vault.go index 72a6c24a5..b90327d90 100644 --- a/command/setup_vault.go +++ b/command/setup_vault.go @@ -564,30 +564,6 @@ func (s *SetupVaultCommand) createNamespace(ns string) error { return nil } -// askQuestion asks question to user until they provide a valid response. -func (s *SetupVaultCommand) askQuestion(question string) bool { - for { - answer, err := s.Ui.Ask(s.Colorize().Color(fmt.Sprintf("[?] %s", question))) - if err != nil { - if err.Error() != "interrupted" { - s.Ui.Output(err.Error()) - os.Exit(1) - } - os.Exit(0) - } - - switch strings.TrimSpace(strings.ToLower(answer)) { - case "", "y", "yes": - return true - case "n", "no": - return false - default: - s.Ui.Output(fmt.Sprintf(`%q is not a valid response, please answer "yes" or "no".`, answer)) - continue - } - } -} - func (s *SetupVaultCommand) handleNo() { s.Ui.Warn(` By answering "no" to any of these questions, you are risking an incorrect Vault diff --git a/command/volume_claim.go b/command/volume_claim.go new file mode 100644 index 000000000..5f66279e6 --- /dev/null +++ b/command/volume_claim.go @@ -0,0 +1,46 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "strings" + + "github.com/hashicorp/cli" +) + +// ensure interface satisfaction +var _ cli.Command = &VolumeClaimCommand{} + +type VolumeClaimCommand struct { + Meta +} + +func (c *VolumeClaimCommand) Help() string { + helpText := ` +Usage: nomad volume claim [options] + + volume claim groups commands that interact with volumes claims. + + List existing volume claims: + $ nomad volume claim list + + Delete an existing volume claim: + $ nomad volume claim delete + + Please see the individual subcommand help for detailed usage information. +` + return strings.TrimSpace(helpText) +} + +func (c *VolumeClaimCommand) Name() string { + return "volume claim" +} + +func (c *VolumeClaimCommand) Synopsis() string { + return "Interact with volume claims" +} + +func (c *VolumeClaimCommand) Run(args []string) int { + return cli.RunResultHelp +} diff --git a/command/volume_claim_delete.go b/command/volume_claim_delete.go new file mode 100644 index 000000000..6969235ee --- /dev/null +++ b/command/volume_claim_delete.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/api" +) + +// ensure interface satisfaction +var _ cli.Command = &VolumeClaimDeleteCommand{} + +var warning string = ` + If you delete a volume claim, the allocation that uses this claim to "stick" + to a particular volume ID will no longer use it upon its next reschedule or + migration. The deployment of the task group the allocation runs will still + claim another feasible volume ID during reschedule or replacement. +` + +type VolumeClaimDeleteCommand struct { + Meta + + autoYes bool +} + +func (c *VolumeClaimDeleteCommand) Help() string { + helpText := ` +Usage: nomad volume claim delete + + volume claim delete is used to delete existing host volume claim by claim ID. +` + warning + ` +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + ` + +Delete options: + + -y + Automatically answers "yes" to all the questions, making the deletion + non-interactive. Defaults to "false". + +` + return strings.TrimSpace(helpText) +} + +func (c *VolumeClaimDeleteCommand) Name() string { + return "volume claim delete" +} + +func (c *VolumeClaimDeleteCommand) Synopsis() string { + return "Delete existing volume claim" +} + +func (c *VolumeClaimDeleteCommand) Run(args []string) int { + flags := c.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.BoolVar(&c.autoYes, "y", false, "") + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that the last argument is the claim ID to delete + if len(flags.Args()) != 1 { + c.Ui.Error("This command takes one argument: ") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + if !isTty() && !c.autoYes { + c.Ui.Error("This command requires -y option when running in non-interactive mode") + return 1 + } + + claimID := flags.Args()[0] + + if !c.autoYes { + c.Ui.Warn(warning) + if !c.askQuestion(fmt.Sprintf("Are you sure you want to delete task group host volume claim %s? [Y/n]", claimID)) { + return 0 + } + } + + // Get the HTTP client + client, err := c.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + if len(claimID) == shortId { + claimID = sanitizeUUIDPrefix(claimID) + claims, _, err := client.TaskGroupHostVolumeClaims().List(nil, &api.QueryOptions{Prefix: claimID}) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying claims: %s", err)) + return 1 + } + // Return error if no claims are found + if len(claims) == 0 { + c.Ui.Error(fmt.Sprintf("No claim(s) with prefix %q found", claimID)) + return 1 + } + if len(claims) > 1 { + // Dump the output + c.Ui.Error(fmt.Sprintf("Prefix matched multiple claims\n\n%s", formatClaims(claims, fullId))) + return 1 + } + claimID = claims[0].ID + } + + // Delete the specified claim + _, err = client.TaskGroupHostVolumeClaims().Delete(claimID, nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error deleting claim: %s", err)) + return 1 + } + + // Give some feedback to indicate the deletion was successful. + c.Ui.Output(fmt.Sprintf("Task group host volume claim %s successfully deleted", claimID)) + return 0 +} diff --git a/command/volume_claim_delete_test.go b/command/volume_claim_delete_test.go new file mode 100644 index 000000000..c8745d93f --- /dev/null +++ b/command/volume_claim_delete_test.go @@ -0,0 +1,107 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "testing" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/shoenig/test/must" +) + +func TestVolumeClaimDeleteCommand_Run(t *testing.T) { + ci.Parallel(t) + + config := func(c *agent.Config) { + c.ACL.Enabled = true + } + + srv, _, url := testServer(t, true, config) + state := srv.Agent.Server().State() + defer srv.Shutdown() + + // get an ACL token + token := mock.CreatePolicyAndToken(t, state, 999, "good", + `namespace "*" { capabilities = ["host-volume-write"] } + node { policy = "write" }`) + must.NotNil(t, token) + + longID := uuid.Generate() + shortID := longID[0:8] + longID2 := uuid.Generate() + longID2 = shortID + longID2[8:] + + // Create some test claims + existingClaims := []*structs.TaskGroupHostVolumeClaim{ + { + ID: longID, + Namespace: structs.DefaultNamespace, + JobID: "foo", + TaskGroupName: "foo", + VolumeID: uuid.Generate(), + VolumeName: "bar", + }, + // different NS + { + ID: uuid.Generate(), + Namespace: "foo", + JobID: "foo", + TaskGroupName: "foo", + VolumeID: uuid.Generate(), + VolumeName: "foo", + }, + { + ID: longID2, // same prefix as the longID + Namespace: structs.DefaultNamespace, + JobID: "bar", + TaskGroupName: "foo", + VolumeID: uuid.Generate(), + VolumeName: "foo", + }, + } + + for _, claim := range existingClaims { + must.NoError(t, state.UpsertTaskGroupHostVolumeClaim(structs.MsgTypeTestSetup, 1000, claim)) + } + + ui := cli.NewMockUi() + cmd := &VolumeClaimDeleteCommand{Meta: Meta{Ui: ui, flagAddress: url}} + + // Delete with an invalid token fails + invalidToken := mock.ACLToken() + must.One(t, cmd.Run([]string{"-address=" + url, "-token=" + invalidToken.SecretID, "-y", existingClaims[0].ID})) + out := ui.ErrorWriter.String() + must.StrContains(t, out, "Permission denied") + ui.ErrorWriter.Reset() + + // Delete with a valid token, but short ID that matches multiple claims + must.One(t, cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID, "-y", shortID})) + out = ui.ErrorWriter.String() + must.StrContains(t, out, "matched multiple claims") + ui.ErrorWriter.Reset() + + // Delete with a valid token + must.Zero(t, cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID, "-y", existingClaims[0].ID})) + out = ui.OutputWriter.String() + must.StrContains(t, out, "successfully deleted") + + ui.OutputWriter.Reset() + + // List and make sure there is just 1 claim left (we have no permissions to read foo ns) + listCmd := &VolumeClaimListCommand{Meta: Meta{Ui: ui, flagAddress: url}} + must.Zero(t, listCmd.Run([]string{ + "-address=" + url, + "-token=" + token.SecretID, + })) + out = ui.OutputWriter.String() + + must.StrContains(t, out, shortID) + + ui.OutputWriter.Reset() +} diff --git a/command/volume_claim_list.go b/command/volume_claim_list.go new file mode 100644 index 000000000..852415ace --- /dev/null +++ b/command/volume_claim_list.go @@ -0,0 +1,160 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/api" + "github.com/posener/complete" +) + +// ensure interface satisfaction +var _ cli.Command = &VolumeClaimListCommand{} + +type VolumeClaimListCommand struct { + Meta + + job string + taskGroup string + volumeName string + + length int + verbose bool + json bool + tmpl string +} + +func (c *VolumeClaimListCommand) Help() string { + helpText := ` +Usage: nomad volume claim list [options] + + volume claim list is used to list existing host volume claims. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + ` + +List Options: + + -job + Filter volume claims by job ID. + + -group + Filter volumes claims by task-group name. + + -volume-name + Filter volumes claims by volume name. + + -verbose + Display full information. + + -json + Output the host volume claims in a JSON format. + -t + Format and display the host volume claims using a Go template. +` + return strings.TrimSpace(helpText) +} + +func (c *VolumeClaimListCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-job": complete.PredictNothing, + "-group": complete.PredictNothing, + "-volume-name": complete.PredictNothing, + "-verbose": complete.PredictNothing, + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +func (c *VolumeClaimListCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *VolumeClaimListCommand) Name() string { + return "volume claim list" +} + +func (c *VolumeClaimListCommand) Synopsis() string { + return "List existing host volume claims" +} + +func (c *VolumeClaimListCommand) Run(args []string) int { + flags := c.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.StringVar(&c.job, "job", "", "") + flags.StringVar(&c.taskGroup, "group", "", "") + flags.StringVar(&c.volumeName, "volume-name", "", "") + flags.BoolVar(&c.json, "json", false, "") + flags.BoolVar(&c.verbose, "verbose", false, "") + flags.StringVar(&c.tmpl, "t", "", "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got no arguments + if len(flags.Args()) != 0 { + c.Ui.Error("This command takes no arguments") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // Truncate the id unless full length is requested + c.length = shortId + if c.verbose { + c.length = fullId + } + + // Get the HTTP client + client, err := c.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + claims, _, err := client.TaskGroupHostVolumeClaims().List(&api.TaskGroupHostVolumeClaimsListRequest{ + JobID: c.job, + TaskGroup: c.taskGroup, + VolumeName: c.volumeName, + }, nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error listing task group host volume claims: %s", err)) + return 1 + } + + if c.json || len(c.tmpl) > 0 { + out, err := Format(c.json, c.tmpl, claims) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + c.Ui.Output(out) + return 0 + } + + c.Ui.Output(formatClaims(claims, c.length)) + return 0 +} + +func formatClaims(claims []*api.TaskGroupHostVolumeClaim, length int) string { + if len(claims) == 0 { + return "No task group host volume claims found" + } + + output := make([]string, 0, len(claims)+1) + output = append(output, "ID|Namespace|Job ID|Volume ID|Volume Name") + for _, claim := range claims { + output = append(output, fmt.Sprintf( + "%s|%s|%s|%s|%s", + limit(claim.ID, length), claim.Namespace, claim.JobID, limit(claim.VolumeID, length), claim.VolumeName)) + } + + return formatList(output) +} diff --git a/command/volume_claim_list_test.go b/command/volume_claim_list_test.go new file mode 100644 index 000000000..e82285875 --- /dev/null +++ b/command/volume_claim_list_test.go @@ -0,0 +1,130 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "testing" + + "github.com/hashicorp/cli" + "github.com/shoenig/test/must" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" +) + +func TestVolumeClaimListCommand_Run(t *testing.T) { + ci.Parallel(t) + + config := func(c *agent.Config) { + c.ACL.Enabled = true + } + + srv, _, url := testServer(t, true, config) + state := srv.Agent.Server().State() + defer srv.Shutdown() + + // get an ACL token + token := mock.CreatePolicyAndToken(t, state, 999, "good", + `namespace "*" { capabilities = ["host-volume-read"] } + node { policy = "read" }`) + must.NotNil(t, token) + + // Create some test claims + existingClaims := []*structs.TaskGroupHostVolumeClaim{ + { + ID: uuid.Generate(), + Namespace: structs.DefaultNamespace, + JobID: "foo", + TaskGroupName: "foo", + VolumeID: uuid.Generate(), + VolumeName: "bar", + }, + // different NS + { + ID: uuid.Generate(), + Namespace: "foo", + JobID: "foo", + TaskGroupName: "foo", + VolumeID: uuid.Generate(), + VolumeName: "foo", + }, + // different Job + { + ID: uuid.Generate(), + Namespace: structs.DefaultNamespace, + JobID: "bar", + TaskGroupName: "foo", + VolumeID: uuid.Generate(), + VolumeName: "foo", + }, + // different tg + { + ID: uuid.Generate(), + Namespace: structs.DefaultNamespace, + JobID: "foo", + TaskGroupName: "bar", + VolumeID: uuid.Generate(), + VolumeName: "foo", + }, + // different volume name + { + ID: uuid.Generate(), + Namespace: structs.DefaultNamespace, + JobID: "foo", + TaskGroupName: "bar", + VolumeID: uuid.Generate(), + VolumeName: "bar", + }, + } + + for _, claim := range existingClaims { + must.NoError(t, state.UpsertTaskGroupHostVolumeClaim(structs.MsgTypeTestSetup, 1000, claim)) + } + + ui := cli.NewMockUi() + cmd := &VolumeClaimListCommand{Meta: Meta{Ui: ui, flagAddress: url}} + + // List with an invalid token fails + invalidToken := mock.ACLToken() + code := cmd.Run([]string{"-address=" + url, "-token=" + invalidToken.SecretID}) + must.One(t, code) + + // List with no token at all + code = cmd.Run([]string{"-address=" + url}) + must.One(t, code) + + // List with a valid token + code = cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID, "-verbose"}) + must.Zero(t, code) + out := ui.OutputWriter.String() + must.StrContains(t, out, existingClaims[0].ID) + + // List json + must.Zero(t, cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID, "-json"})) + out = ui.OutputWriter.String() + must.StrContains(t, out, "CreateIndex") + + ui.OutputWriter.Reset() + + // Filter by job "foo" and volume name "foo" + must.Zero(t, cmd.Run([]string{ + "-address=" + url, + "-token=" + token.SecretID, + "-job=" + "foo", + "-volume-name=" + "foo", + "-verbose", + })) + out = ui.OutputWriter.String() + + // only existingClaims[3] matches this filter + must.StrContains(t, out, existingClaims[3].ID) + for _, id := range []string{existingClaims[0].ID, existingClaims[1].ID, existingClaims[2].ID, existingClaims[4].ID} { + must.StrNotContains(t, out, id, must.Sprintf("did not expect to find %s in %s", id, out)) + } + + ui.OutputWriter.Reset() +} diff --git a/nomad/fsm.go b/nomad/fsm.go index 8f37c2cbf..1fa20e010 100644 --- a/nomad/fsm.go +++ b/nomad/fsm.go @@ -390,7 +390,7 @@ func (n *nomadFSM) Apply(log *raft.Log) interface{} { case structs.HostVolumeDeleteRequestType: return n.applyHostVolumeDelete(msgType, buf[1:], log.Index) case structs.TaskGroupHostVolumeClaimDeleteRequestType: - return n.applyTaskGroupHostVolumeClaimDelete(msgType, buf[1:], log.Index) + return n.applyTaskGroupHostVolumeClaimDelete(buf[1:], log.Index) } // Check enterprise only message types. @@ -2452,7 +2452,7 @@ func (n *nomadFSM) applyHostVolumeDelete(msgType structs.MessageType, buf []byte return nil } -func (n *nomadFSM) applyTaskGroupHostVolumeClaimDelete(msgType structs.MessageType, buf []byte, index uint64) interface{} { +func (n *nomadFSM) applyTaskGroupHostVolumeClaimDelete(buf []byte, index uint64) interface{} { defer metrics.MeasureSince([]string{"nomad", "fsm", "apply_task_group_host_volume_claim_delete"}, time.Now()) var req structs.TaskGroupVolumeClaimDeleteRequest