server: Add new server.client_introduction config block. (#26315)

The new configuration block exposes some key options which allow
cluster administrators to control certain client introduction
behaviours.

This change introduces the new block and plumbing, so that it is
exposed in the Nomad server for consumption via internal processes.
This commit is contained in:
James Rasell
2025-07-22 09:50:19 +02:00
committed by GitHub
parent dce4284361
commit 7466dd71b2
11 changed files with 555 additions and 8 deletions

View File

@@ -656,6 +656,24 @@ func convertServerConfig(agentConfig *Config) (*nomad.Config, error) {
runtime.NumCPU())
}
// If the operator has specified a client introduction server config block,
// translate this into the internal server configuration object.
if agentConfig.Server.ClientIntroduction != nil {
if agentConfig.Server.ClientIntroduction.Enforcement != "" {
conf.NodeIntroductionConfig.Enforcement = agentConfig.Server.ClientIntroduction.Enforcement
}
if agentConfig.Server.ClientIntroduction.DefaultIdentityTTL > 0 {
conf.NodeIntroductionConfig.DefaultIdentityTTL = agentConfig.Server.ClientIntroduction.DefaultIdentityTTL
}
if agentConfig.Server.ClientIntroduction.MaxIdentityTTL > 0 {
conf.NodeIntroductionConfig.MaxIdentityTTL = agentConfig.Server.ClientIntroduction.MaxIdentityTTL
}
}
if err := conf.NodeIntroductionConfig.Validate(); err != nil {
return nil, fmt.Errorf("invalid server.client_introduction configuration: %w", err)
}
return conf, nil
}

View File

@@ -1898,3 +1898,60 @@ func TestAgent_ServerConfig_JobDefaultPriority_Bad(t *testing.T) {
})
}
}
func Test_convertServerConfig_clientIntroduction(t *testing.T) {
ci.Parallel(t)
testCases := []struct {
name string
inputClientIntroduction *ClientIntroduction
expectedNodeIntroductionConfig *structs.NodeIntroductionConfig
}{
{
name: "nil client introduction",
inputClientIntroduction: nil,
expectedNodeIntroductionConfig: &structs.NodeIntroductionConfig{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
},
},
{
name: "partial override",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "strict",
},
expectedNodeIntroductionConfig: &structs.NodeIntroductionConfig{
Enforcement: "strict",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
},
},
{
name: "partial override",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "strict",
DefaultIdentityTTL: 50 * time.Minute,
MaxIdentityTTL: 300 * time.Minute,
},
expectedNodeIntroductionConfig: &structs.NodeIntroductionConfig{
Enforcement: "strict",
DefaultIdentityTTL: 50 * time.Minute,
MaxIdentityTTL: 300 * time.Minute,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
baseConfig := DevConfig(nil)
must.NoError(t, baseConfig.normalizeAddrs())
baseConfig.Server.ClientIntroduction = tc.inputClientIntroduction
serverConf, err := convertServerConfig(baseConfig)
must.NoError(t, err)
must.Eq(t, tc.expectedNodeIntroductionConfig, serverConf.NodeIntroductionConfig)
})
}
}

View File

@@ -519,6 +519,11 @@ type ServerConfig struct {
// by withholding peers until enough servers join.
BootstrapExpect int `hcl:"bootstrap_expect"`
// ClientIntroduction is the configuration block that configures the client
// introduction feature. This feature allows servers to validate requests
// and perform enforcement actions on client registrations.
ClientIntroduction *ClientIntroduction `hcl:"client_introduction"`
// DataDir is the directory to store our state in
DataDir string `hcl:"data_dir"`
@@ -785,6 +790,7 @@ func (s *ServerConfig) Copy() *ServerConfig {
ns.JobDefaultPriority = pointer.Copy(s.JobDefaultPriority)
ns.JobMaxPriority = pointer.Copy(s.JobMaxPriority)
ns.JobTrackedVersions = pointer.Copy(s.JobTrackedVersions)
ns.ClientIntroduction = s.ClientIntroduction.Copy()
return &ns
}
@@ -1017,6 +1023,110 @@ func (s *Search) Copy() *Search {
return &ns
}
// ClientIntroduction is the server configuration block that configures the
// client introduction feature. This feature allows servers to validate requests
// and perform enforcement actions on client registrations.
type ClientIntroduction struct {
// Enforcement is the level of enforcement that the server will apply to
// client registrations. This can be one of "none", "warn", or "strict"
// which is also declared within ClientIntroductionEnforcementValues.
Enforcement string `hcl:"enforcement"`
// DefaultIdentityTTL is the TTL assigned to client introduction identities
// that are generated by a caller who did not provide a TTL.
DefaultIdentityTTL time.Duration
DefaultIdentityTTLHCL string `hcl:"default_identity_ttl" json:"-"`
// MaxIdentityTTL is the maximum TTL that can be assigned to a client
// introduction identity. This is used to validate the TTL provided by
// caller and allows operators a method to limit TTL requests.
MaxIdentityTTL time.Duration
MaxIdentityTTLHCL string `hcl:"max_identity_ttl" json:"-"`
// ExtraKeysHCL is used by hcl to surface unexpected keys within the
// configuration block. Without this, unexpected keys will be silently
// ignored.
ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"`
}
// ClientIntroductionEnforcementValues are the valid values for the client
// introduction enforcement setting.
var ClientIntroductionEnforcementValues = []string{"none", "warn", "strict"}
// Copy creates a copy of the ClientIntroduction configuration block. All fields
// are copied, including the ExtraKeysHCL field which is used by HCL to surface
// unexpected keys within the configuration block.
func (c *ClientIntroduction) Copy() *ClientIntroduction {
if c == nil {
return nil
}
newCI := *c
newCI.ExtraKeysHCL = slices.Clone(c.ExtraKeysHCL)
return &newCI
}
// Merge performs a merge of two ClientIntroduction configuration blocks
// overwriting the values in the first block with the values in the block passed
// into the function.
func (c *ClientIntroduction) Merge(z *ClientIntroduction) *ClientIntroduction {
if c == nil {
return z
}
result := *c
if z == nil {
return &result
}
if z.Enforcement != "" {
result.Enforcement = z.Enforcement
}
if z.DefaultIdentityTTL > 0 {
result.DefaultIdentityTTL = z.DefaultIdentityTTL
}
if z.MaxIdentityTTL > 0 {
result.MaxIdentityTTL = z.MaxIdentityTTL
}
if len(z.ExtraKeysHCL) > 0 {
result.ExtraKeysHCL = append(result.ExtraKeysHCL, z.ExtraKeysHCL...)
}
return &result
}
// Validate performs validation on the ClientIntroduction configuration block to
// ensure the values are set correctly for use by the server.
func (c *ClientIntroduction) Validate() error {
if c == nil {
return nil
}
if c.Enforcement == "" {
return errors.New("client_introduction.enforcement must be set")
}
if !slices.Contains(ClientIntroductionEnforcementValues, c.Enforcement) {
return fmt.Errorf("client_introduction.enforcement must be one of %v",
ClientIntroductionEnforcementValues)
}
if c.DefaultIdentityTTL < 1 {
return errors.New("client_introduction.default_identity_ttl must be greater one")
}
if c.MaxIdentityTTL < 1 {
return errors.New("client_introduction.max_identity_ttl must be greater one")
}
if c.MaxIdentityTTL < c.DefaultIdentityTTL {
return errors.New("client_introduction.max_identity_ttl must be greater than default_identity_ttl")
}
return nil
}
// ServerJoin is used in both clients and servers to bootstrap connections to
// servers
type ServerJoin struct {
@@ -2456,6 +2566,11 @@ func (s *ServerConfig) Merge(b *ServerConfig) *ServerConfig {
result.StartTimeout = b.StartTimeout
}
// Merge the client introduction config.
if b.ClientIntroduction != nil {
result.ClientIntroduction = result.ClientIntroduction.Merge(b.ClientIntroduction)
}
// Add the schedulers
result.EnabledSchedulers = append(result.EnabledSchedulers, b.EnabledSchedulers...)

View File

@@ -54,6 +54,7 @@ func ParseConfigFile(path string) (*Config, error) {
},
},
Server: &ServerConfig{
ClientIntroduction: &ClientIntroduction{},
PlanRejectionTracker: &PlanRejectionTracker{},
ServerJoin: &ServerJoin{},
},
@@ -189,6 +190,18 @@ func ParseConfigFile(path string) (*Config, error) {
{"rpc.connection_write_timeout", &c.RPC.ConnectionWriteTimeout, &c.RPC.ConnectionWriteTimeoutHCL, nil},
{"rpc.stream_open_timeout", &c.RPC.StreamOpenTimeout, &c.RPC.StreamOpenTimeoutHCL, nil},
{"rpc.stream_close_timeout", &c.RPC.StreamCloseTimeout, &c.RPC.StreamCloseTimeoutHCL, nil},
{
"server.client_introduction.default_identity_ttl",
&c.Server.ClientIntroduction.DefaultIdentityTTL,
&c.Server.ClientIntroduction.DefaultIdentityTTLHCL,
nil,
},
{
"server.client_introduction.max_identity_ttl",
&c.Server.ClientIntroduction.MaxIdentityTTL,
&c.Server.ClientIntroduction.MaxIdentityTTLHCL,
nil,
},
}
// Parse durations for Consul and Vault config blocks if provided.

View File

@@ -160,6 +160,13 @@ var basicConfig = &Config{
JobDefaultPriority: pointer.Of(100),
JobMaxPriority: pointer.Of(200),
StartTimeout: "1m",
ClientIntroduction: &ClientIntroduction{
Enforcement: "warn",
DefaultIdentityTTLHCL: "5m",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTLHCL: "30m",
MaxIdentityTTL: 30 * time.Minute,
},
},
ACL: &ACLConfig{
Enabled: true,
@@ -620,6 +627,9 @@ func (c *Config) addDefaults() {
if c.Server.PlanRejectionTracker == nil {
c.Server.PlanRejectionTracker = &PlanRejectionTracker{}
}
if c.Server.ClientIntroduction == nil {
c.Server.ClientIntroduction = &ClientIntroduction{}
}
if c.Reporting == nil {
c.Reporting = &config.ReportingConfig{
License: &config.LicenseReportingConfig{
@@ -712,6 +722,7 @@ var sample0 = &Config{
NodeWindow: 31 * time.Minute,
NodeWindowHCL: "31m",
},
ClientIntroduction: &ClientIntroduction{},
},
ACL: &ACLConfig{
Enabled: true,
@@ -821,6 +832,7 @@ var sample1 = &Config{
NodeWindow: 31 * time.Minute,
NodeWindowHCL: "31m",
},
ClientIntroduction: &ClientIntroduction{},
},
ACL: &ACLConfig{
Enabled: true,

View File

@@ -1260,6 +1260,123 @@ func TestIsMissingPort(t *testing.T) {
}
}
func TestClientIntroduction_Copy(t *testing.T) {
ci.Parallel(t)
clientIntro := &ClientIntroduction{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
}
copiedClientIntro := clientIntro.Copy()
// Ensure the copied object contains the same values, but the underlying
// pointer address is different.
must.Eq(t, clientIntro, copiedClientIntro)
must.NotEq(t, fmt.Sprintf("%p", clientIntro), fmt.Sprintf("%p", copiedClientIntro))
}
func TestClientIntroduction_Merge(t *testing.T) {
ci.Parallel(t)
clientIntro1 := &ClientIntroduction{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
ExtraKeysHCL: []string{"key1", "key2"},
}
clientIntro2 := &ClientIntroduction{
Enforcement: "strict",
DefaultIdentityTTL: 30 * time.Minute,
MaxIdentityTTL: 60 * time.Minute,
ExtraKeysHCL: []string{"key3", "key4"},
}
expectedClientIntro := &ClientIntroduction{
Enforcement: "strict",
DefaultIdentityTTL: 30 * time.Minute,
MaxIdentityTTL: 60 * time.Minute,
ExtraKeysHCL: []string{"key1", "key2", "key3", "key4"},
}
must.Eq(t, expectedClientIntro, clientIntro1.Merge(clientIntro2))
}
func TestClientIntroduction_Validate(t *testing.T) {
ci.Parallel(t)
testCases := []struct {
name string
inputClientIntroduction *ClientIntroduction
expectedError bool
}{
{
name: "nil block",
inputClientIntroduction: nil,
expectedError: false,
},
{
name: "empty enforcement",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "",
},
expectedError: true,
},
{
name: "invalid enforcement",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "nuclear",
},
expectedError: true,
},
{
name: "invalid default_identity_ttl",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "warn",
DefaultIdentityTTL: 0,
},
expectedError: true,
},
{
name: "invalid max_identity_ttl",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 0,
},
expectedError: true,
},
{
name: "invalid ttl combination",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 4 * time.Minute,
},
expectedError: true,
},
{
name: "valid",
inputClientIntroduction: &ClientIntroduction{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
},
expectedError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actualOutput := tc.inputClientIntroduction.Validate()
if tc.expectedError {
must.Error(t, actualOutput)
} else {
must.NoError(t, actualOutput)
}
})
}
}
func TestMergeServerJoin(t *testing.T) {
ci.Parallel(t)

View File

@@ -168,6 +168,12 @@ server {
}
}
client_introduction {
enforcement = "warn"
default_identity_ttl = "5m"
max_identity_ttl = "30m"
}
license_path = "/tmp/nomad.hclic"
}

View File

@@ -361,6 +361,11 @@
]
}
],
"client_introduction": {
"enforcement": "warn",
"default_identity_ttl": "5m",
"max_identity_ttl": "30m"
},
"upgrade_version": "0.8.0",
"license_path": "/tmp/nomad.hclic",
"job_default_priority": 100,

View File

@@ -448,6 +448,11 @@ type Config struct {
// considered healthy. Without this, the server can hang indefinitely
// waiting for these.
StartTimeout time.Duration
// NodeIntroductionConfig is the configuration for the node introduction
// feature. This feature allows servers to validate node registration
// requests and perform the appropriate enforcement actions.
NodeIntroductionConfig *structs.NodeIntroductionConfig
}
func (c *Config) Copy() *Config {
@@ -477,6 +482,7 @@ func (c *Config) Copy() *Config {
nc.LicenseConfig = c.LicenseConfig.Copy()
nc.SearchConfig = c.SearchConfig.Copy()
nc.KEKProviderConfigs = helper.CopySlice(c.KEKProviderConfigs)
nc.NodeIntroductionConfig = c.NodeIntroductionConfig.Copy()
return &nc
}
@@ -657,6 +663,7 @@ func DefaultConfig() *Config {
JobMaxPriority: structs.JobDefaultMaxPriority,
JobTrackedVersions: structs.JobDefaultTrackedVersions,
StartTimeout: 30 * time.Second,
NodeIntroductionConfig: structs.DefaultNodeIntroductionConfig(),
}
// Enable all known schedulers by default

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/nomad/helper/uuid"
)
@@ -497,10 +498,10 @@ type NodeMetaResponse struct {
// NodeIdentityClaims represents the claims for a Nomad node identity JWT.
type NodeIdentityClaims struct {
NodeID string `json:"nomad_node_id"`
NodePool string `json:"nomad_node_pool"`
NodeClass string `json:"nomad_node_class"`
NodeDatacenter string `json:"nomad_node_datacenter"`
NodeID string `json:"nomad_node_id,omitempty"`
NodePool string `json:"nomad_node_pool,omitempty"`
NodeClass string `json:"nomad_node_class,omitempty"`
NodeDatacenter string `json:"nomad_node_datacenter,omitempty"`
}
// GenerateNodeIdentityClaims creates a new NodeIdentityClaims for the given
@@ -736,3 +737,98 @@ type NodeIdentityRenewReq struct {
}
type NodeIdentityRenewResp struct{}
// DefaultNodeIntroductionConfig returns a default and fully hydrated
// configuration object for the node introduction feature.
func DefaultNodeIntroductionConfig() *NodeIntroductionConfig {
return &NodeIntroductionConfig{
Enforcement: NodeIntroductionEnforcementWarn,
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
}
}
const (
// NodeIntroductionEnforcementNone means secure intro token, and the
// pre-1.11 workflow that uses an empty authentication token can be used for
// initial client registration. No enforcement is applied and no emits are
// emitted based on the registration introduction.
NodeIntroductionEnforcementNone = "none"
// NodeIntroductionEnforcementWarn means secure intro token, and the
// pre-1.11 workflow that uses an empty authentication token can be used for
// initial client registration. The server will emit a log and a metric for
// each registration that does not use an introduction token.
NodeIntroductionEnforcementWarn = "warn"
// NodeIntroductionEnforcementStrict means initial client registration must
// use a secure introduction token. The server will reject any registration
// that does not use an introduction token.
NodeIntroductionEnforcementStrict = "strict"
)
// NodeIntroductionConfig is the server configuration block that configures
// the client introduction feature. This feature allows servers to validate
// requests and perform enforcement actions on client registrations.
type NodeIntroductionConfig struct {
// Enforcement is the level of enforcement that the server will apply to
// client registrations. This can be one of "none", "warn", or "strict"
// which are defined as NodeIntroductionEnforcementNone,
// NodeIntroductionEnforcementWarn, and NodeIntroductionEnforcementStrict
// respectively.
Enforcement string
// DefaultIdentityTTL is the TTL assigned to client introduction identities
// that are generated by a caller who did not provide a TTL.
DefaultIdentityTTL time.Duration
// MaxIdentityTTL is the maximum TTL that can be assigned to a client
// introduction identity. This is used to validate the TTL provided by
// caller and allows operators a method to limit TTL requests.
MaxIdentityTTL time.Duration
}
// Copy creates a copy of the node introduction configuration block.
func (n *NodeIntroductionConfig) Copy() *NodeIntroductionConfig {
if n == nil {
return nil
}
newCI := *n
return &newCI
}
// Validate checks that the node introduction configuration is valid.
func (n *NodeIntroductionConfig) Validate() error {
if n == nil {
return fmt.Errorf("cannot be empty")
}
var mErr *multierror.Error
switch n.Enforcement {
case NodeIntroductionEnforcementNone,
NodeIntroductionEnforcementWarn,
NodeIntroductionEnforcementStrict:
default:
mErr = multierror.Append(mErr, fmt.Errorf("invalid enforcement %q", n.Enforcement))
}
if n.DefaultIdentityTTL < 1 {
mErr = multierror.Append(mErr, errors.New("default_identity_ttl must be greater than 0"))
}
if n.MaxIdentityTTL < 1 {
mErr = multierror.Append(mErr, errors.New("max_identity_ttl must be greater than 0"))
}
if n.MaxIdentityTTL < n.DefaultIdentityTTL {
mErr = multierror.Append(mErr, errors.New(
"max_identity_ttl must be greater than or equal to default_identity_ttl",
))
}
return mErr.ErrorOrNil()
}

View File

@@ -4,6 +4,7 @@
package structs
import (
"fmt"
"testing"
"time"
@@ -269,10 +270,10 @@ func TestGenerateNodeIdentityClaims(t *testing.T) {
claims := GenerateNodeIdentityClaims(node, "euw", 10*time.Minute)
must.Eq(t, "node-id-1", claims.NodeID)
must.Eq(t, "custom-pool", claims.NodePool)
must.Eq(t, "custom-class", claims.NodeClass)
must.Eq(t, "euw2", claims.NodeDatacenter)
must.Eq(t, "node-id-1", claims.NodeIdentityClaims.NodeID)
must.Eq(t, "custom-pool", claims.NodeIdentityClaims.NodePool)
must.Eq(t, "custom-class", claims.NodeIdentityClaims.NodeClass)
must.Eq(t, "euw2", claims.NodeIdentityClaims.NodeDatacenter)
must.StrEqFold(t, "node:euw:custom-pool:node-id-1:default", claims.Subject)
must.Eq(t, []string{IdentityDefaultAud}, claims.Audience)
must.NotNil(t, claims.ID)
@@ -649,3 +650,103 @@ func TestNodeUpdateStatusRequest_IdentitySigningErrorIsTerminal(t *testing.T) {
})
}
}
func Test_DefaultNodeIntroductionConfig(t *testing.T) {
ci.Parallel(t)
expected := &NodeIntroductionConfig{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
}
must.Eq(t, expected, DefaultNodeIntroductionConfig())
}
func TestNodeIntroductionConfig_Copy(t *testing.T) {
ci.Parallel(t)
nodeIntro := &NodeIntroductionConfig{
Enforcement: "warn",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
}
copiedNodeIntro := nodeIntro.Copy()
// Ensure the copied object contains the same values, but the underlying
// pointer address is different.
must.Eq(t, nodeIntro, copiedNodeIntro)
must.NotEq(t, fmt.Sprintf("%p", nodeIntro), fmt.Sprintf("%p", copiedNodeIntro))
}
func TestNodeIntroductionConfig_Validate(t *testing.T) {
ci.Parallel(t)
testCases := []struct {
name string
inputNodeIntroductionConfig *NodeIntroductionConfig
expectedErrorContains string
}{
{
name: "nil config",
inputNodeIntroductionConfig: nil,
expectedErrorContains: "cannot be empty",
},
{
name: "incorrect enforcement",
inputNodeIntroductionConfig: &NodeIntroductionConfig{
Enforcement: "invalid",
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 30 * time.Minute,
},
expectedErrorContains: "invalid enforcement",
},
{
name: "incorrect default identity TTL",
inputNodeIntroductionConfig: &NodeIntroductionConfig{
Enforcement: NodeIntroductionEnforcementStrict,
DefaultIdentityTTL: 0,
MaxIdentityTTL: 30 * time.Minute,
},
expectedErrorContains: "default_identity_ttl must be greater than 0",
},
{
name: "incorrect max identity TTL",
inputNodeIntroductionConfig: &NodeIntroductionConfig{
Enforcement: NodeIntroductionEnforcementStrict,
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 0,
},
expectedErrorContains: "max_identity_ttl must be greater than 0",
},
{
name: "incorrect max identity TTL greater than default identity TTL",
inputNodeIntroductionConfig: &NodeIntroductionConfig{
Enforcement: NodeIntroductionEnforcementStrict,
DefaultIdentityTTL: 5 * time.Minute,
MaxIdentityTTL: 0,
},
expectedErrorContains: "max_identity_ttl must be greater than or equal to default_identity_ttl",
},
{
name: "valid",
inputNodeIntroductionConfig: &NodeIntroductionConfig{
Enforcement: NodeIntroductionEnforcementStrict,
DefaultIdentityTTL: 15 * time.Minute,
MaxIdentityTTL: 45 * time.Minute,
},
expectedErrorContains: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actualError := tc.inputNodeIntroductionConfig.Validate()
if tc.expectedErrorContains == "" {
must.NoError(t, actualError)
} else {
must.ErrorContains(t, actualError, tc.expectedErrorContains)
}
})
}
}