From 96e539ee87d979970e6c4a0e0cd0ee4e5f0a711b Mon Sep 17 00:00:00 2001 From: Tim Gross Date: Fri, 17 Jan 2025 11:41:56 -0500 Subject: [PATCH] dynamic host volumes quotas (#24871) Allow users to configure a host volumes quota in MB. This will be enforced at the time of provisioning via create/register RPCs. This changeset is the CE version of ENT/2114. Ref: https://github.com/hashicorp/nomad-enterprise/pull/2114 Ref: https://hashicorp.atlassian.net/browse/NET-11549 --- api/quota.go | 5 ++ command/quota_apply.go | 40 +++++++++++++--- command/quota_apply_test.go | 53 ++++++++++++++++++++++ command/quota_init.go | 10 ++-- nomad/state/state_store_host_volumes.go | 8 +++- nomad/state/state_store_host_volumes_ce.go | 16 +++++++ 6 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 nomad/state/state_store_host_volumes_ce.go diff --git a/api/quota.go b/api/quota.go index 402c57a02..3423440d3 100644 --- a/api/quota.go +++ b/api/quota.go @@ -157,6 +157,11 @@ type QuotaStorageResources struct { // Variable.EncryptedData, in megabytes (2^20 bytes). A value of zero is // treated as unlimited and a negative value is treated as fully disallowed. VariablesMB int `hcl:"variables"` + + // HostVolumesMB is the maximum provisioned size of all dynamic host + // volumes, in megabytes (2^20 bytes). A value of zero is treated as + // unlimited and a negative value is treated as fully disallowed. + HostVolumesMB int `hcl:"host_volumes"` } // QuotaUsage is the resource usage of a Quota diff --git a/command/quota_apply.go b/command/quota_apply.go index 1c0373d70..4e91acf5e 100644 --- a/command/quota_apply.go +++ b/command/quota_apply.go @@ -12,6 +12,7 @@ import ( "os" "strings" + humanize "github.com/dustin/go-humanize" multierror "github.com/hashicorp/go-multierror" "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/ast" @@ -319,19 +320,44 @@ func parseStorageResource(storageBlocks *ast.ObjectList) (*api.QuotaStorageResou return nil, errors.New("only one storage block is allowed") } block := storageBlocks.Items[0] - valid := []string{"variables"} + valid := []string{"variables", "host_volumes"} 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 { + + var m map[string]any + if err := hcl.DecodeObject(&m, block.Val); err != nil { return nil, err } - if err := mapstructure.WeakDecode(m, &storage); err != nil { - return nil, err + + variablesLimit, err := parseQuotaMegabytes(m["variables"]) + if err != nil { + return nil, fmt.Errorf("invalid variables limit: %v", err) + } + hostVolumesLimit, err := parseQuotaMegabytes(m["host_volumes"]) + if err != nil { + return nil, fmt.Errorf("invalid host_volumes limit: %v", err) + } + + return &api.QuotaStorageResources{ + VariablesMB: variablesLimit, + HostVolumesMB: hostVolumesLimit, + }, nil +} + +func parseQuotaMegabytes(raw any) (int, error) { + switch val := raw.(type) { + case string: + b, err := humanize.ParseBytes(val) + if err != nil { + return 0, fmt.Errorf("could not parse value as bytes: %v", err) + } + return int(b >> 20), nil + case int: + return val, nil + default: + return 0, fmt.Errorf("invalid type %T", raw) } - return &storage, nil } func parseDeviceResource(result *[]*api.RequestedDevice, list *ast.ObjectList) error { diff --git a/command/quota_apply_test.go b/command/quota_apply_test.go index c7955f27c..7682662db 100644 --- a/command/quota_apply_test.go +++ b/command/quota_apply_test.go @@ -8,7 +8,10 @@ import ( "testing" "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/helper/pointer" + "github.com/shoenig/test/must" ) func TestQuotaApplyCommand_Implements(t *testing.T) { @@ -38,3 +41,53 @@ func TestQuotaApplyCommand_Fails(t *testing.T) { } ui.ErrorWriter.Reset() } + +func TestQuotaParse(t *testing.T) { + + in := []byte(` +name = "default-quota" +description = "Limit the shared default namespace" + +limit { + region = "global" + region_limit { + cores = 0 + cpu = 2500 + memory = 1000 + memory_max = 1000 + device "nvidia/gpu/1080ti" { + count = 1 + } + storage { + variables = 1000 # in MB + host_volumes = "100 GiB" + } + } +} +`) + + spec, err := parseQuotaSpec(in) + must.NoError(t, err) + + must.Eq(t, &api.QuotaSpec{ + Name: "default-quota", + Description: "Limit the shared default namespace", + Limits: []*api.QuotaLimit{{ + Region: "global", + RegionLimit: &api.QuotaResources{ + CPU: pointer.Of(2500), + Cores: pointer.Of(0), + MemoryMB: pointer.Of(1000), + MemoryMaxMB: pointer.Of(1000), + Devices: []*api.RequestedDevice{{ + Name: "nvidia/gpu/1080ti", + Count: pointer.Of(uint64(1)), + }}, + Storage: &api.QuotaStorageResources{ + VariablesMB: 1000, + HostVolumesMB: 102_400, + }, + }, + }}, + }, spec) +} diff --git a/command/quota_init.go b/command/quota_init.go index a338e0cb8..3cc844181 100644 --- a/command/quota_init.go +++ b/command/quota_init.go @@ -127,7 +127,8 @@ limit { count = 1 } storage { - variables = 1000 + variables = 1000 # in MB + host_volumes = 100000 # in MB } } } @@ -151,9 +152,10 @@ var defaultJsonQuotaSpec = strings.TrimSpace(` "Count": 1 } ], - "Storage": { - "Variables": 1000 -} + "Storage": { + "Variables": 1000, + "HostVolumes": 100000 + } } } ] diff --git a/nomad/state/state_store_host_volumes.go b/nomad/state/state_store_host_volumes.go index 6826638a3..50294dcb1 100644 --- a/nomad/state/state_store_host_volumes.go +++ b/nomad/state/state_store_host_volumes.go @@ -66,14 +66,20 @@ func (s *StateStore) UpsertHostVolume(index uint64, vol *structs.HostVolume) err if err != nil { return err } + var old *structs.HostVolume if obj != nil { - old := obj.(*structs.HostVolume) + old = obj.(*structs.HostVolume) vol.CreateIndex = old.CreateIndex vol.CreateTime = old.CreateTime } else { vol.CreateIndex = index } + err = s.enforceHostVolumeQuotaTxn(txn, index, vol, old, true) + if err != nil { + return err + } + // If the fingerprint is written from the node before the create RPC handler // completes, we'll never update from the initial pending, so reconcile that // here diff --git a/nomad/state/state_store_host_volumes_ce.go b/nomad/state/state_store_host_volumes_ce.go new file mode 100644 index 000000000..b2022eaa1 --- /dev/null +++ b/nomad/state/state_store_host_volumes_ce.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !ent + +package state + +import "github.com/hashicorp/nomad/nomad/structs" + +func (s *StateStore) EnforceHostVolumeQuota(_ *structs.HostVolume, _ *structs.HostVolume) error { + return nil +} + +func (s *StateStore) enforceHostVolumeQuotaTxn(_ Txn, _ uint64, _ *structs.HostVolume, _ *structs.HostVolume, _ bool) error { + return nil +}