Files
nomad/command/volume_create_host.go
Tim Gross d1352b285d dynamic host volumes: Enterprise stubs and refactor API (#24545)
Most Nomad upsert RPCs accept a single object with the notable exception of
CSI. But in CSI we don't actually expose this to users except through the Go
API. It deeply complicates how we present errors to users, especially once
Sentinel policy enforcement enters the mix.

Refactor the `HostVolume.Create` and `HostVolume.Register` RPCs to take a single
volume instead of a slice of volumes.

Add a stub function for Enterprise policy enforcement. This requires splitting
out placement from the `createVolume` function so that we can ensure we've
completed placement before trying to enforce policy.

Ref: https://github.com/hashicorp/nomad/pull/24479
2024-12-19 09:25:54 -05:00

364 lines
8.8 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"context"
"fmt"
"strconv"
"time"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/helper"
"github.com/mitchellh/go-glint"
"github.com/mitchellh/go-glint/components"
"github.com/mitchellh/mapstructure"
)
func (c *VolumeCreateCommand) hostVolumeCreate(
client *api.Client, ast *ast.File, detach, verbose bool) int {
vol, err := decodeHostVolume(ast)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error decoding the volume definition: %s", err))
return 1
}
req := &api.HostVolumeCreateRequest{
Volume: vol,
}
vol, _, err = client.HostVolumes().Create(req, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating volume: %s", err))
return 1
}
var volID string
var lastIndex uint64
if detach || vol.State == api.HostVolumeStateReady {
c.Ui.Output(fmt.Sprintf(
"Created host volume %s with ID %s", vol.Name, vol.ID))
return 0
} else {
c.Ui.Output(fmt.Sprintf(
"==> Created host volume %s with ID %s", vol.Name, vol.ID))
volID = vol.ID
lastIndex = vol.ModifyIndex
}
err = c.monitorHostVolume(client, volID, lastIndex, verbose)
if err != nil {
c.Ui.Error(fmt.Sprintf("==> %s: %v", formatTime(time.Now()), err.Error()))
return 1
}
return 0
}
func (c *VolumeCreateCommand) monitorHostVolume(client *api.Client, id string, lastIndex uint64, verbose bool) error {
length := shortId
if verbose {
length = fullId
}
opts := formatOpts{
verbose: verbose,
short: !verbose,
length: length,
}
if isStdoutTerminal() {
return c.ttyMonitor(client, id, lastIndex, opts)
} else {
return c.nottyMonitor(client, id, lastIndex, opts)
}
}
func (c *VolumeCreateCommand) ttyMonitor(client *api.Client, id string, lastIndex uint64, opts formatOpts) error {
gUi := glint.New()
spinner := glint.Layout(
components.Spinner(),
glint.Text(fmt.Sprintf(" Monitoring volume %q in progress...", limit(id, opts.length))),
).Row().MarginLeft(2)
refreshRate := 100 * time.Millisecond
gUi.SetRefreshRate(refreshRate)
gUi.Set(spinner)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go gUi.Render(ctx)
qOpts := &api.QueryOptions{
AllowStale: true,
WaitIndex: lastIndex,
WaitTime: time.Second * 5,
}
var statusComponent *glint.LayoutComponent
var endSpinner *glint.LayoutComponent
DONE:
for {
vol, meta, err := client.HostVolumes().Get(id, qOpts)
if err != nil {
return err
}
str, err := formatHostVolume(vol, opts)
if err != nil {
// should never happen b/c we don't pass json/template via opts here
return err
}
statusComponent = glint.Layout(
glint.Text(""),
glint.Text(formatTime(time.Now())),
glint.Text(c.Colorize().Color(str)),
).MarginLeft(4)
statusComponent = glint.Layout(statusComponent)
gUi.Set(spinner, statusComponent)
endSpinner = glint.Layout(
components.Spinner(),
glint.Text(fmt.Sprintf(" Host volume %q %s", limit(id, opts.length), vol.State)),
).Row().MarginLeft(2)
switch vol.State {
case api.HostVolumeStateReady:
endSpinner = glint.Layout(
glint.Text(fmt.Sprintf("✓ Host volume %q %s", limit(id, opts.length), vol.State)),
).Row().MarginLeft(2)
break DONE
case api.HostVolumeStateDeleted:
endSpinner = glint.Layout(
glint.Text(fmt.Sprintf("! Host volume %q %s", limit(id, opts.length), vol.State)),
).Row().MarginLeft(2)
break DONE
default:
qOpts.WaitIndex = meta.LastIndex
continue
}
}
// Render one final time with completion message
gUi.Set(endSpinner, statusComponent, glint.Text(""))
gUi.RenderFrame()
return nil
}
func (c *VolumeCreateCommand) nottyMonitor(client *api.Client, id string, lastIndex uint64, opts formatOpts) error {
c.Ui.Info(fmt.Sprintf("==> %s: Monitoring volume %q...",
formatTime(time.Now()), limit(id, opts.length)))
for {
vol, _, err := client.HostVolumes().Get(id, &api.QueryOptions{
WaitIndex: lastIndex,
WaitTime: time.Second * 5,
})
if err != nil {
return err
}
if vol.State == api.HostVolumeStateReady {
c.Ui.Info(fmt.Sprintf("==> %s: Volume %q ready",
formatTime(time.Now()), limit(vol.Name, opts.length)))
return nil
}
}
}
func decodeHostVolume(input *ast.File) (*api.HostVolume, error) {
var err error
vol := &api.HostVolume{}
list, ok := input.Node.(*ast.ObjectList)
if !ok {
return nil, fmt.Errorf("error parsing: root should be an object")
}
// Decode the full thing into a map[string]interface for ease
var m map[string]any
err = hcl.DecodeObject(&m, list)
if err != nil {
return nil, err
}
// Need to manually parse these fields/blocks
delete(m, "capability")
delete(m, "constraint")
delete(m, "capacity_max")
delete(m, "capacity_min")
delete(m, "type")
// Decode the rest
err = mapstructure.WeakDecode(m, vol)
if err != nil {
return nil, err
}
capacityMin, err := parseCapacityBytes(list.Filter("capacity_min"))
if err != nil {
return nil, fmt.Errorf("invalid capacity_min: %v", err)
}
vol.RequestedCapacityMinBytes = capacityMin
capacityMax, err := parseCapacityBytes(list.Filter("capacity_max"))
if err != nil {
return nil, fmt.Errorf("invalid capacity_max: %v", err)
}
vol.RequestedCapacityMaxBytes = capacityMax
if o := list.Filter("constraint"); len(o.Items) > 0 {
if err := parseConstraints(&vol.Constraints, o); err != nil {
return nil, fmt.Errorf("invalid constraint: %v", err)
}
}
if o := list.Filter("capability"); len(o.Items) > 0 {
if err := parseHostVolumeCapabilities(&vol.RequestedCapabilities, o); err != nil {
return nil, fmt.Errorf("invalid capability: %v", err)
}
}
return vol, nil
}
func parseHostVolumeCapabilities(result *[]*api.HostVolumeCapability, list *ast.ObjectList) error {
for _, o := range list.Elem().Items {
valid := []string{"access_mode", "attachment_mode"}
if err := helper.CheckHCLKeys(o.Val, valid); err != nil {
return err
}
ot, ok := o.Val.(*ast.ObjectType)
if !ok {
break
}
var m map[string]any
if err := hcl.DecodeObject(&m, ot.List); err != nil {
return err
}
var cap *api.HostVolumeCapability
if err := mapstructure.WeakDecode(&m, &cap); err != nil {
return err
}
*result = append(*result, cap)
}
return nil
}
func parseConstraints(result *[]*api.Constraint, list *ast.ObjectList) error {
for _, o := range list.Elem().Items {
valid := []string{
"attribute",
"distinct_hosts",
"distinct_property",
"operator",
"regexp",
"set_contains",
"value",
"version",
"semver",
}
if err := helper.CheckHCLKeys(o.Val, valid); err != nil {
return err
}
var m map[string]any
if err := hcl.DecodeObject(&m, o.Val); err != nil {
return err
}
m["LTarget"] = m["attribute"]
m["RTarget"] = m["value"]
m["Operand"] = m["operator"]
// If "version" is provided, set the operand
// to "version" and the value to the "RTarget"
if constraint, ok := m[api.ConstraintVersion]; ok {
m["Operand"] = api.ConstraintVersion
m["RTarget"] = constraint
}
// If "semver" is provided, set the operand
// to "semver" and the value to the "RTarget"
if constraint, ok := m[api.ConstraintSemver]; ok {
m["Operand"] = api.ConstraintSemver
m["RTarget"] = constraint
}
// If "regexp" is provided, set the operand
// to "regexp" and the value to the "RTarget"
if constraint, ok := m[api.ConstraintRegex]; ok {
m["Operand"] = api.ConstraintRegex
m["RTarget"] = constraint
}
// If "set_contains" is provided, set the operand
// to "set_contains" and the value to the "RTarget"
if constraint, ok := m[api.ConstraintSetContains]; ok {
m["Operand"] = api.ConstraintSetContains
m["RTarget"] = constraint
}
if value, ok := m[api.ConstraintDistinctHosts]; ok {
enabled, err := parseBool(value)
if err != nil {
return fmt.Errorf("distinct_hosts should be set to true or false; %v", err)
}
// If it is not enabled, skip the constraint.
if !enabled {
continue
}
m["Operand"] = api.ConstraintDistinctHosts
m["RTarget"] = strconv.FormatBool(enabled)
}
if property, ok := m[api.ConstraintDistinctProperty]; ok {
m["Operand"] = api.ConstraintDistinctProperty
m["LTarget"] = property
}
// Build the constraint
var c api.Constraint
if err := mapstructure.WeakDecode(m, &c); err != nil {
return err
}
if c.Operand == "" {
c.Operand = "="
}
*result = append(*result, &c)
}
return nil
}
// parseBool takes an interface value and tries to convert it to a boolean and
// returns an error if the type can't be converted.
func parseBool(value any) (bool, error) {
var enabled bool
var err error
switch data := value.(type) {
case string:
enabled, err = strconv.ParseBool(data)
case bool:
enabled = data
default:
err = fmt.Errorf("%v couldn't be converted to boolean value", value)
}
return enabled, err
}