diff --git a/client/client.go b/client/client.go index f411a300b..8ac24b5ec 100644 --- a/client/client.go +++ b/client/client.go @@ -477,8 +477,8 @@ func NewClient(cfg *config.Config, consulCatalog consul.CatalogAPI, consulProxie // Create the dynamic workload users pool c.users = dynamic.New(&dynamic.PoolConfig{ - MinUGID: 80_000, // TODO(shoenig) plumb client config - MaxUGID: 89_999, // TODO(shoenig) plumb client config + MinUGID: cfg.Users.MinDynamicUser, + MaxUGID: cfg.Users.MaxDynamicUser, }) // Create the cpu core partition manager diff --git a/client/config/config.go b/client/config/config.go index 151e5db7e..14c2a0299 100644 --- a/client/config/config.go +++ b/client/config/config.go @@ -328,6 +328,9 @@ type Config struct { // Drain configuration from the agent's config file. Drain *DrainConfig + // Uesrs configuration from the agent's config file. + Users *UsersConfig + // ExtraAllocHooks are run with other allocation hooks, mainly for testing. ExtraAllocHooks []interfaces.RunnerHook } @@ -757,6 +760,7 @@ func (c *Config) Copy() *Config { nc.TemplateConfig = c.TemplateConfig.Copy() nc.ReservableCores = slices.Clone(c.ReservableCores) nc.Artifact = c.Artifact.Copy() + nc.Users = c.Users.Copy() return &nc } @@ -805,6 +809,10 @@ func DefaultConfig() *Config { CgroupParent: "nomad.slice", // SETH todo MaxDynamicPort: structs.DefaultMinDynamicPort, MinDynamicPort: structs.DefaultMaxDynamicPort, + Users: &UsersConfig{ + MinDynamicUser: 80_000, + MaxDynamicUser: 89_999, + }, } return cfg diff --git a/client/config/users.go b/client/config/users.go new file mode 100644 index 000000000..a8275e24f --- /dev/null +++ b/client/config/users.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import sconfig "github.com/hashicorp/nomad/nomad/structs/config" + +// UsersConfig configures things related to operating system users. +type UsersConfig struct { + // MinDynamicUser is the lowest uid/gid for use in the dynamic users pool. + MinDynamicUser int + + // MaxDynamicUser is the highest uid/gid for use in the dynamic users pool. + MaxDynamicUser int +} + +func UsersConfigFromAgent(c *sconfig.UsersConfig) *UsersConfig { + return &UsersConfig{ + MinDynamicUser: *c.MinDynamicUser, + MaxDynamicUser: *c.MaxDynamicUser, + } +} + +func (u *UsersConfig) Copy() *UsersConfig { + if u == nil { + return nil + } + return &UsersConfig{ + MinDynamicUser: u.MinDynamicUser, + MaxDynamicUser: u.MaxDynamicUser, + } +} diff --git a/client/config/users_test.go b/client/config/users_test.go new file mode 100644 index 000000000..25d6a8ded --- /dev/null +++ b/client/config/users_test.go @@ -0,0 +1,59 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/nomad/structs/config" + "github.com/shoenig/test/must" +) + +func TestUsersConfigFromAgent(t *testing.T) { + ci.Parallel(t) + + cases := []struct { + name string + config *config.UsersConfig + exp *UsersConfig + }{ + { + name: "from default", + config: config.DefaultUsersConfig(), + exp: &UsersConfig{ + MinDynamicUser: 80_000, + MaxDynamicUser: 89_999, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := UsersConfigFromAgent(tc.config) + must.Eq(t, tc.exp, got) + }) + } +} + +func TestUsersConfig_Copy(t *testing.T) { + ci.Parallel(t) + + orig := &UsersConfig{ + MinDynamicUser: 70100, + MaxDynamicUser: 70200, + } + + configCopy := orig.Copy() + must.Eq(t, orig, configCopy) + + // modify copy and make sure original does not change + configCopy.MinDynamicUser = 100 + configCopy.MaxDynamicUser = 200 + + must.Eq(t, &UsersConfig{ + MinDynamicUser: 70100, + MaxDynamicUser: 70200, + }, orig) +} diff --git a/command/agent/agent.go b/command/agent/agent.go index aef55fd22..f8b39990b 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -902,6 +902,8 @@ func convertClientConfig(agentConfig *Config) (*clientconfig.Config, error) { } conf.Drain = drainConfig + conf.Users = clientconfig.UsersConfigFromAgent(agentConfig.Client.Users) + return conf, nil } diff --git a/command/agent/config.go b/command/agent/config.go index 34e58b4ce..346a794c9 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -380,6 +380,9 @@ type ClientConfig struct { // Drain specifies whether to drain the client on shutdown; ignored in dev mode. Drain *config.DrainConfig `hcl:"drain_on_shutdown"` + // Users is used to configure parameters around operating system users. + Users *config.UsersConfig `hcl:"users"` + // ExtraKeysHCL is used by hcl to surface unexpected keys ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"` } @@ -403,6 +406,7 @@ func (c *ClientConfig) Copy() *ClientConfig { nc.NomadServiceDiscovery = pointer.Copy(c.NomadServiceDiscovery) nc.Artifact = c.Artifact.Copy() nc.Drain = c.Drain.Copy() + nc.Users = c.Users.Copy() nc.ExtraKeysHCL = slices.Clone(c.ExtraKeysHCL) return &nc } @@ -1356,6 +1360,7 @@ func DefaultConfig() *Config { NomadServiceDiscovery: pointer.Of(true), Artifact: config.DefaultArtifactConfig(), Drain: nil, + Users: config.DefaultUsersConfig(), }, Server: &ServerConfig{ Enabled: false, @@ -2361,6 +2366,7 @@ func (a *ClientConfig) Merge(b *ClientConfig) *ClientConfig { result.Artifact = a.Artifact.Merge(b.Artifact) result.Drain = a.Drain.Merge(b.Drain) + result.Users = a.Users.Merge(b.Users) return &result } diff --git a/helper/users/dynamic/pool.go b/helper/users/dynamic/pool.go index 86a1cc624..8b020dab1 100644 --- a/helper/users/dynamic/pool.go +++ b/helper/users/dynamic/pool.go @@ -24,6 +24,9 @@ var ( // none indicates no dynamic user const none = 0 +// doNotEnable indicates functionality should be disabled +const doNotEnable = -1 + // A UGID is a combination User (UID) and Group (GID). Since Nomad is // allocating these values together from the same pool it can ensure they are // always matching values, thus encoding them with one value. @@ -60,11 +63,21 @@ type PoolConfig struct { MaxUGID int } +// disable will return true if either min or max is set to Disable (-1), +// indicating the client should not enable the dynamic workload users +// functionality +func (p *PoolConfig) disable() bool { + return p.MinUGID == doNotEnable || p.MaxUGID == doNotEnable +} + // New creates a Pool with the given PoolConfig options. func New(opts *PoolConfig) Pool { if opts == nil { panic("bug: users pool cannot be nil") } + if opts.disable() { + return new(noopPool) + } if opts.MinUGID < 0 { panic("bug: users pool min must be >= 0") } @@ -81,6 +94,20 @@ func New(opts *PoolConfig) Pool { } } +// noopPool is an implementation of Pool that does not allow acquiring ugids +type noopPool struct{} + +func (*noopPool) Restore(UGID) {} +func (*noopPool) Acquire() (UGID, error) { + return 0, errors.New("dynamic workload users disabled") +} +func (*noopPool) Release(UGID) error { + // avoid giving an error if a client is restarted with a new config + // that disables dynamic workload users but still has a task running + // making use of one + return nil +} + type pool struct { min UGID max UGID diff --git a/nomad/structs/config/artifact_test.go b/nomad/structs/config/artifact_test.go index f78feecb8..144893f3d 100644 --- a/nomad/structs/config/artifact_test.go +++ b/nomad/structs/config/artifact_test.go @@ -418,8 +418,7 @@ func TestArtifactConfig_Validate(t *testing.T) { err := a.Validate() if tc.expErr != "" { - must.Error(t, err) - must.StrContains(t, err.Error(), tc.expErr) + must.ErrorContains(t, err, tc.expErr) } else { must.NoError(t, err) } diff --git a/nomad/structs/config/users.go b/nomad/structs/config/users.go new file mode 100644 index 000000000..8d204f358 --- /dev/null +++ b/nomad/structs/config/users.go @@ -0,0 +1,100 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import ( + "errors" + + "github.com/hashicorp/nomad/helper/pointer" +) + +// UsersConfig configures things related to operating system users. +type UsersConfig struct { + // MinDynamicUser is the lowest uid/gid for use in the dynamic users pool. + MinDynamicUser *int `hcl:"dynamic_user_min"` + + // MaxDynamicUser is the highest uid/gid for use in the dynamic users pool. + MaxDynamicUser *int `hcl:"dynamic_user_max"` +} + +// Copy returns a deep copy of the Users struct. +func (u *UsersConfig) Copy() *UsersConfig { + if u == nil { + return nil + } + return &UsersConfig{ + MinDynamicUser: pointer.Copy(u.MinDynamicUser), + MaxDynamicUser: pointer.Copy(u.MaxDynamicUser), + } +} + +// Merge returns a new Users where non-empty/nil fields in the argument have +// higher precedence. +func (u *UsersConfig) Merge(o *UsersConfig) *UsersConfig { + switch { + case u == nil: + return o.Copy() + case o == nil: + return u.Copy() + default: + return &UsersConfig{ + MinDynamicUser: pointer.Merge(u.MinDynamicUser, o.MinDynamicUser), + MaxDynamicUser: pointer.Merge(u.MaxDynamicUser, o.MaxDynamicUser), + } + } +} + +// Equal returns whether u and o are the same. +func (u *UsersConfig) Equal(o *UsersConfig) bool { + if u == nil || o == nil { + return u == o + } + switch { + case !pointer.Eq(u.MinDynamicUser, o.MinDynamicUser): + return false + case !pointer.Eq(u.MaxDynamicUser, o.MaxDynamicUser): + return false + default: + return true + } +} + +var ( + errUsersUnset = errors.New("users must not be nil") + errDynamicUserMinUnset = errors.New("dynamic_user_min must be set") + errDynamicUserMinInvalid = errors.New("dynamic_user_min must not be negative") + errDynamicUserMaxUnset = errors.New("dynamic_user_max must be set") + errDynamicUserMaxInvalid = errors.New("dynamic_user_max must not be negative") +) + +// Validate whether UsersConfig is valid. +// +// Note that -1 is a valid value for min/max dynamic users, as this is used +// to indicate the dynamic workload users feature should be disabled. +func (u *UsersConfig) Validate() error { + if u == nil { + return errUsersUnset + } + if u.MinDynamicUser == nil { + return errDynamicUserMinUnset + } + if *u.MinDynamicUser < -1 { + return errDynamicUserMinInvalid + } + if u.MaxDynamicUser == nil { + return errDynamicUserMaxUnset + } + if *u.MaxDynamicUser < -1 { + return errDynamicUserMaxInvalid + } + return nil +} + +// DefaultUsersConfig returns the default users configuration. +func DefaultUsersConfig() *UsersConfig { + return &UsersConfig{ + MinDynamicUser: pointer.Of(80_000), + MaxDynamicUser: pointer.Of(89_999), + } +} diff --git a/nomad/structs/config/users_test.go b/nomad/structs/config/users_test.go new file mode 100644 index 000000000..aed036b3e --- /dev/null +++ b/nomad/structs/config/users_test.go @@ -0,0 +1,139 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/helper/pointer" + "github.com/shoenig/test/must" +) + +func TestUsersConfig_Copy(t *testing.T) { + ci.Parallel(t) + + a := DefaultUsersConfig() + b := a.Copy() + must.Equal(t, a, b) + must.Equal(t, b, a) + + a.MaxDynamicUser = pointer.Of(1000) + must.NotEqual(t, a, b) + must.NotEqual(t, b, a) +} + +func TestUsersConfig_Merge(t *testing.T) { + ci.Parallel(t) + + cases := []struct { + name string + source *UsersConfig + other *UsersConfig + exp *UsersConfig + }{ + { + name: "merge all fields", + source: &UsersConfig{ + MinDynamicUser: pointer.Of(100), + MaxDynamicUser: pointer.Of(200), + }, + other: &UsersConfig{ + MinDynamicUser: pointer.Of(3000), + MaxDynamicUser: pointer.Of(4000), + }, + exp: &UsersConfig{ + MinDynamicUser: pointer.Of(3000), + MaxDynamicUser: pointer.Of(4000), + }, + }, + { + name: "null source", + source: nil, + other: &UsersConfig{ + MinDynamicUser: pointer.Of(100), + MaxDynamicUser: pointer.Of(200), + }, + exp: &UsersConfig{ + MinDynamicUser: pointer.Of(100), + MaxDynamicUser: pointer.Of(200), + }, + }, + { + name: "null other", + other: nil, + source: &UsersConfig{ + MinDynamicUser: pointer.Of(100), + MaxDynamicUser: pointer.Of(200), + }, + exp: &UsersConfig{ + MinDynamicUser: pointer.Of(100), + MaxDynamicUser: pointer.Of(200), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.source.Merge(tc.other) + must.Equal(t, tc.exp, got) + }) + } +} + +func TestUsersConfig_Validate(t *testing.T) { + ci.Parallel(t) + + // default config should be valid of course + must.NoError(t, DefaultUsersConfig().Validate()) + + // nil config is not valid + must.ErrorIs(t, ((*UsersConfig)(nil)).Validate(), errUsersUnset) + + cases := []struct { + name string + modify func(*UsersConfig) + exp error + }{ + { + name: "min dynamic user not set", + modify: func(u *UsersConfig) { + u.MinDynamicUser = nil + }, + exp: errDynamicUserMinUnset, + }, + { + name: "min dynamic user not valid", + modify: func(u *UsersConfig) { + u.MinDynamicUser = pointer.Of(-2) + }, + exp: errDynamicUserMinInvalid, + }, + { + name: "max dynamic user not set", + modify: func(u *UsersConfig) { + u.MaxDynamicUser = nil + }, + exp: errDynamicUserMaxUnset, + }, + { + name: "max dynamic user not valid", + modify: func(u *UsersConfig) { + u.MaxDynamicUser = pointer.Of(-2) + }, + exp: errDynamicUserMaxInvalid, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u := DefaultUsersConfig() + if tc.modify != nil { + tc.modify(u) + } + err := u.Validate() + must.ErrorIs(t, err, tc.exp) + }) + } +}