Files
nomad/command/quota_apply.go
Tim Gross 3a11a0b1e1 quotas: refactor storage limit specification (#24785)
In anticipation of having quotas for dynamic host volumes, we want the user
experience of the storage limits to feel integrated with the other resource
limits. This is currently prevented by reusing the `Resources` type instead of
having a specific type for `QuotaResources`.

Update the quota limit/usage types to use a `QuotaResources` that includes a new
storage resources quota block. The wire format for the two types are compatible
such that we can migrate the existing variables limit in the FSM.

Also fixes improper parallelism in the quota init test where we change working
directory to avoid file write conflicts but this breaks when multiple tests are
executed in the same process.

Ref: https://github.com/hashicorp/nomad-enterprise/pull/2096
2025-01-13 09:25:00 -05:00

373 lines
8.5 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/helper"
"github.com/mitchellh/mapstructure"
"github.com/posener/complete"
)
type QuotaApplyCommand struct {
Meta
}
func (c *QuotaApplyCommand) Help() string {
helpText := `
Usage: nomad quota apply [options] <input>
Apply is used to create or update a quota specification. The specification file
will be read from stdin by specifying "-", otherwise a path to the file is
expected.
If ACLs are enabled, this command requires a token with the 'quota:write'
capability.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Apply Options:
-json
Parse the input as a JSON quota specification.
`
return strings.TrimSpace(helpText)
}
func (c *QuotaApplyCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
})
}
func (c *QuotaApplyCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictFiles("*")
}
func (c *QuotaApplyCommand) Synopsis() string {
return "Create or update a quota specification"
}
func (c *QuotaApplyCommand) Name() string { return "quota apply" }
func (c *QuotaApplyCommand) Run(args []string) int {
var jsonInput bool
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&jsonInput, "json", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we get exactly one argument
args = flags.Args()
if l := len(args); l != 1 {
c.Ui.Error("This command takes one argument: <input>")
c.Ui.Error(commandErrorText(c))
return 1
}
// Read the file contents
file := args[0]
var rawQuota []byte
var err error
if file == "-" {
rawQuota, err = io.ReadAll(os.Stdin)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read stdin: %v", err))
return 1
}
} else {
rawQuota, err = os.ReadFile(file)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read file: %v", err))
return 1
}
}
var spec *api.QuotaSpec
if jsonInput {
var jsonSpec api.QuotaSpec
dec := json.NewDecoder(bytes.NewBuffer(rawQuota))
if err := dec.Decode(&jsonSpec); err != nil {
c.Ui.Error(fmt.Sprintf("Failed to parse quota: %v", err))
return 1
}
spec = &jsonSpec
} else {
hclSpec, err := parseQuotaSpec(rawQuota)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing quota specification: %s", err))
return 1
}
spec = hclSpec
}
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
_, err = client.Quotas().Register(spec, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error applying quota specification: %s", err))
return 1
}
c.Ui.Output(fmt.Sprintf("Successfully applied quota specification %q!", spec.Name))
return 0
}
// parseQuotaSpec is used to parse the quota specification from HCL
func parseQuotaSpec(input []byte) (*api.QuotaSpec, error) {
root, err := hcl.ParseBytes(input)
if err != nil {
return nil, err
}
// Top-level item should be a list
list, ok := root.Node.(*ast.ObjectList)
if !ok {
return nil, fmt.Errorf("error parsing: root should be an object")
}
var spec api.QuotaSpec
if err := parseQuotaSpecImpl(&spec, list); err != nil {
return nil, err
}
return &spec, nil
}
// parseQuotaSpecImpl parses the quota spec taking as input the AST tree
func parseQuotaSpecImpl(result *api.QuotaSpec, list *ast.ObjectList) error {
// Check for invalid keys
valid := []string{
"name",
"description",
"limit",
}
if err := helper.CheckHCLKeys(list, valid); err != nil {
return err
}
// Decode the full thing into a map[string]interface for ease
var m map[string]interface{}
if err := hcl.DecodeObject(&m, list); err != nil {
return err
}
// Manually parse
delete(m, "limit")
// Decode the rest
if err := mapstructure.WeakDecode(m, result); err != nil {
return err
}
// Parse limits
if o := list.Filter("limit"); len(o.Items) > 0 {
if err := parseQuotaLimits(&result.Limits, o); err != nil {
return multierror.Prefix(err, "limit ->")
}
}
return nil
}
// parseQuotaLimits parses the quota limits
func parseQuotaLimits(result *[]*api.QuotaLimit, list *ast.ObjectList) error {
for _, o := range list.Elem().Items {
// Check for invalid keys
valid := []string{
"region",
"region_limit",
"variables_limit",
}
if err := helper.CheckHCLKeys(o.Val, valid); err != nil {
return err
}
var m map[string]interface{}
if err := hcl.DecodeObject(&m, o.Val); err != nil {
return err
}
// Manually parse
delete(m, "region_limit")
// Decode the rest
var limit api.QuotaLimit
if err := mapstructure.WeakDecode(m, &limit); err != nil {
return err
}
// We need this later
var listVal *ast.ObjectList
if ot, ok := o.Val.(*ast.ObjectType); ok {
listVal = ot.List
} else {
return fmt.Errorf("limit should be an object")
}
// Parse limits
if o := listVal.Filter("region_limit"); len(o.Items) > 0 {
limit.RegionLimit = new(api.QuotaResources)
if err := parseQuotaResource(limit.RegionLimit, o); err != nil {
return multierror.Prefix(err, "region_limit ->")
}
}
*result = append(*result, &limit)
}
return nil
}
// parseQuotaResource parses the region_limit resources
func parseQuotaResource(result *api.QuotaResources, list *ast.ObjectList) error {
list = list.Elem()
if len(list.Items) == 0 {
return nil
}
if len(list.Items) > 1 {
return fmt.Errorf("only one 'region_limit' block allowed per limit")
}
// Get our resource object
o := list.Items[0]
// We need this later
var listVal *ast.ObjectList
if ot, ok := o.Val.(*ast.ObjectType); ok {
listVal = ot.List
} else {
return fmt.Errorf("resource: should be an object")
}
// Check for invalid keys
valid := []string{
"cores",
"cpu",
"memory",
"memory_max",
"device",
"storage",
}
if err := helper.CheckHCLKeys(listVal, valid); err != nil {
return multierror.Prefix(err, "resources ->")
}
var m map[string]interface{}
if err := hcl.DecodeObject(&m, o.Val); err != nil {
return err
}
// Manually parse
delete(m, "device")
delete(m, "storage")
if err := mapstructure.WeakDecode(m, result); err != nil {
return err
}
// Parse devices
if o := listVal.Filter("device"); len(o.Items) > 0 {
result.Devices = make([]*api.RequestedDevice, 0)
if err := parseDeviceResource(&result.Devices, o); err != nil {
return multierror.Prefix(err, "devices ->")
}
}
// Parse storage block
storageBlocks := listVal.Filter("storage")
storage, err := parseStorageResource(storageBlocks)
if err != nil {
return multierror.Prefix(err, "storage ->")
}
result.Storage = storage
return nil
}
func parseStorageResource(storageBlocks *ast.ObjectList) (*api.QuotaStorageResources, error) {
switch len(storageBlocks.Items) {
case 0:
return nil, nil
case 1:
default:
return nil, errors.New("only one storage block is allowed")
}
block := storageBlocks.Items[0]
valid := []string{"variables"}
if err := helper.CheckHCLKeys(block.Val, valid); err != nil {
return nil, err
}
var storage api.QuotaStorageResources
var m map[string]interface{}
if err := hcl.DecodeObject(&storage, block.Val); err != nil {
return nil, err
}
if err := mapstructure.WeakDecode(m, &storage); err != nil {
return nil, err
}
return &storage, nil
}
func parseDeviceResource(result *[]*api.RequestedDevice, list *ast.ObjectList) error {
for idx, o := range list.Items {
if l := len(o.Keys); l == 0 {
return multierror.Prefix(fmt.Errorf("missing device name"), fmt.Sprintf("resources, device[%d]->", idx))
} else if l > 1 {
return multierror.Prefix(fmt.Errorf("only one name may be specified"), fmt.Sprintf("resources, device[%d]->", idx))
}
name := o.Keys[0].Token.Value().(string)
// Check for invalid keys
valid := []string{
"name",
"count",
}
if err := helper.CheckHCLKeys(o.Val, valid); err != nil {
return err
}
// Set the name
var device api.RequestedDevice
device.Name = name
var m map[string]interface{}
if err := hcl.DecodeObject(&m, o.Val); err != nil {
return err
}
if err := mapstructure.WeakDecode(m, &device); err != nil {
return err
}
*result = append(*result, &device)
}
return nil
}