stateful deployments: task group host volume claims CLI (#25116)

CLI for interacting with task group host volume claims.
This commit is contained in:
Piotr Kazmierczak
2025-02-27 17:04:48 +01:00
committed by GitHub
parent 6ae1444cf4
commit 73a193f6d9
11 changed files with 609 additions and 51 deletions

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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)

View File

@@ -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

View File

@@ -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

46
command/volume_claim.go Normal file
View File

@@ -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 <subcommand> [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 <id>
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
}

View File

@@ -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 <id>
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: <claim_id>")
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
}

View File

@@ -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()
}

View File

@@ -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 <id>
Filter volume claims by job ID.
-group <name>
Filter volumes claims by task-group name.
-volume-name <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)
}

View File

@@ -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()
}

View File

@@ -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