diff --git a/internal/app/machined/pkg/controllers/k8s/node_taint_spec.go b/internal/app/machined/pkg/controllers/k8s/node_taint_spec.go index f8f35286d..e509d9467 100644 --- a/internal/app/machined/pkg/controllers/k8s/node_taint_spec.go +++ b/internal/app/machined/pkg/controllers/k8s/node_taint_spec.go @@ -7,6 +7,7 @@ package k8s import ( "context" "fmt" + "strings" "github.com/cosi-project/runtime/pkg/controller" "github.com/cosi-project/runtime/pkg/safe" @@ -53,7 +54,7 @@ func (ctrl *NodeTaintSpecController) Outputs() []controller.Output { // Run implements controller.Controller interface. // //nolint:gocyclo -func (ctrl *NodeTaintSpecController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) error { +func (ctrl *NodeTaintSpecController) Run(ctx context.Context, r controller.Runtime, _ *zap.Logger) error { for { select { case <-ctx.Done(): @@ -68,16 +69,24 @@ func (ctrl *NodeTaintSpecController) Run(ctx context.Context, r controller.Runti r.StartTrackingOutputs() - if cfg != nil && cfg.Config().Machine() != nil && cfg.Config().Cluster() != nil { - if cfg.Config().Machine().Type().IsControlPlane() && !cfg.Config().Cluster().ScheduleOnControlPlanes() { - if err = safe.WriterModify(ctx, r, k8s.NewNodeTaintSpec(constants.LabelNodeRoleControlPlane), func(k *k8s.NodeTaintSpec) error { - k.TypedSpec().Key = constants.LabelNodeRoleControlPlane - k.TypedSpec().Value = "" - k.TypedSpec().Effect = string(v1.TaintEffectNoSchedule) + if cfg != nil && cfg.Config().Machine() != nil { + if cfg.Config().Cluster() != nil { + if cfg.Config().Machine().Type().IsControlPlane() && !cfg.Config().Cluster().ScheduleOnControlPlanes() { + if err = createTaint(ctx, r, constants.LabelNodeRoleControlPlane, "", string(v1.TaintEffectNoSchedule)); err != nil { + return err + } + } + } - return nil - }); err != nil { - return fmt.Errorf("error updating node taint spec: %w", err) + for key, val := range cfg.Config().Machine().NodeTaints() { + value, effect, found := strings.Cut(val, ":") + if !found { + effect = value + value = "" + } + + if err = createTaint(ctx, r, key, value, effect); err != nil { + return err } } } @@ -87,3 +96,17 @@ func (ctrl *NodeTaintSpecController) Run(ctx context.Context, r controller.Runti } } } + +func createTaint(ctx context.Context, r controller.Runtime, key string, val string, effect string) error { + if err := safe.WriterModify(ctx, r, k8s.NewNodeTaintSpec(key), func(k *k8s.NodeTaintSpec) error { + k.TypedSpec().Key = key + k.TypedSpec().Value = val + k.TypedSpec().Effect = effect + + return nil + }); err != nil { + return fmt.Errorf("error updating node taint spec: %w", err) + } + + return nil +} diff --git a/internal/app/machined/pkg/controllers/k8s/node_taint_spec_test.go b/internal/app/machined/pkg/controllers/k8s/node_taint_spec_test.go index 46556f44d..598937e0e 100644 --- a/internal/app/machined/pkg/controllers/k8s/node_taint_spec_test.go +++ b/internal/app/machined/pkg/controllers/k8s/node_taint_spec_test.go @@ -11,6 +11,7 @@ import ( "github.com/cosi-project/runtime/pkg/resource/rtestutils" "github.com/cosi-project/runtime/pkg/safe" "github.com/cosi-project/runtime/pkg/state" + "github.com/siderolabs/gen/xslices" "github.com/siderolabs/go-pointer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -43,16 +44,19 @@ func TestNodeTaintsSuite(t *testing.T) { }) } -func (suite *NodeTaintsSuite) updateMachineConfig(machineType machine.Type, allowScheduling bool) { +func (suite *NodeTaintsSuite) updateMachineConfig(machineType machine.Type, allowScheduling bool, taints ...customTaint) { cfg, err := safe.StateGetByID[*config.MachineConfig](suite.Ctx(), suite.State(), config.V1Alpha1ID) if err != nil && !state.IsNotFoundError(err) { suite.Require().NoError(err) } + nodeTaints := xslices.ToMap(taints, func(t customTaint) (string, string) { return t.key, t.value }) + if cfg == nil { cfg = config.NewMachineConfig(container.NewV1Alpha1(&v1alpha1.Config{ MachineConfig: &v1alpha1.MachineConfig{ - MachineType: machineType.String(), + MachineType: machineType.String(), + MachineNodeTaints: nodeTaints, }, ClusterConfig: &v1alpha1.ClusterConfig{ AllowSchedulingOnControlPlanes: pointer.To(allowScheduling), @@ -63,6 +67,7 @@ func (suite *NodeTaintsSuite) updateMachineConfig(machineType machine.Type, allo } else { cfg.Container().RawV1Alpha1().ClusterConfig.AllowSchedulingOnControlPlanes = pointer.To(allowScheduling) cfg.Container().RawV1Alpha1().MachineConfig.MachineType = machineType.String() + cfg.Container().RawV1Alpha1().MachineConfig.MachineNodeTaints = nodeTaints suite.Require().NoError(suite.State().Update(suite.Ctx(), cfg)) } } @@ -86,3 +91,28 @@ func (suite *NodeTaintsSuite) TestControlplane() { rtestutils.AssertNoResource[*k8s.NodeTaintSpec](suite.Ctx(), suite.T(), suite.State(), constants.LabelNodeRoleControlPlane) } + +func (suite *NodeTaintsSuite) TestCustomTaints() { + const customTaintKey = "key1" + + suite.updateMachineConfig(machine.TypeControlPlane, false, customTaint{ + key: customTaintKey, + value: "value1:NoSchedule", + }) + + rtestutils.AssertResources(suite.Ctx(), suite.T(), suite.State(), []string{customTaintKey}, + func(labelSpec *k8s.NodeTaintSpec, asrt *assert.Assertions) { + asrt.Equal(customTaintKey, labelSpec.TypedSpec().Key) + asrt.Equal("value1", labelSpec.TypedSpec().Value) + asrt.Equal(string(v1.TaintEffectNoSchedule), labelSpec.TypedSpec().Effect) + }) + + suite.updateMachineConfig(machine.TypeControlPlane, false) + + rtestutils.AssertNoResource[*k8s.NodeTaintSpec](suite.Ctx(), suite.T(), suite.State(), customTaintKey) +} + +type customTaint struct { + key string + value string +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_runtime.go b/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_runtime.go index 2c04dde3c..88967db45 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_runtime.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_runtime.go @@ -147,6 +147,7 @@ func (r *Runtime) CanApplyImmediate(cfg config.Provider) error { newConfig.MachineConfig.MachinePods = currentConfig.MachineConfig.MachinePods newConfig.MachineConfig.MachineSeccompProfiles = currentConfig.MachineConfig.MachineSeccompProfiles newConfig.MachineConfig.MachineNodeLabels = currentConfig.MachineConfig.MachineNodeLabels + newConfig.MachineConfig.MachineNodeTaints = currentConfig.MachineConfig.MachineNodeTaints if newConfig.MachineConfig.MachineFeatures != nil { if currentConfig.MachineConfig.MachineFeatures != nil { diff --git a/internal/integration/api/node-labels.go b/internal/integration/api/node-labels.go index 6805680a1..d3a0e3834 100644 --- a/internal/integration/api/node-labels.go +++ b/internal/integration/api/node-labels.go @@ -63,6 +63,8 @@ func (suite *NodeLabelsSuite) TestUpdateWorker() { suite.testUpdate(node, false) } +const metadataKeyName = "metadata.name=" + // testUpdate cycles through a set of node label updates reverting the change in the end. func (suite *NodeLabelsSuite) testUpdate(node string, isControlplane bool) { k8sNode, err := suite.GetK8sNodeByInternalIP(suite.ctx, node) @@ -71,7 +73,7 @@ func (suite *NodeLabelsSuite) testUpdate(node string, isControlplane bool) { suite.T().Logf("updating labels on node %q (%q)", node, k8sNode.Name) watcher, err := suite.Clientset.CoreV1().Nodes().Watch(suite.ctx, metav1.ListOptions{ - FieldSelector: "metadata.name=" + k8sNode.Name, + FieldSelector: metadataKeyName + k8sNode.Name, Watch: true, }) suite.Require().NoError(err) @@ -135,7 +137,7 @@ func (suite *NodeLabelsSuite) TestAllowScheduling() { suite.T().Logf("updating taints on node %q (%q)", node, k8sNode.Name) watcher, err := suite.Clientset.CoreV1().Nodes().Watch(suite.ctx, metav1.ListOptions{ - FieldSelector: "metadata.name=" + k8sNode.Name, + FieldSelector: metadataKeyName + k8sNode.Name, Watch: true, }) suite.Require().NoError(err) @@ -160,9 +162,9 @@ outer: select { case ev := <-watcher.ResultChan(): k8sNode, ok := ev.Object.(*v1.Node) - suite.Require().True(ok, "watch event is not of type v1.Node") + suite.Require().Truef(ok, "watch event is not of type v1.Node but was %T", ev.Object) - suite.T().Logf("labels %v, taints %v", k8sNode.Labels, k8sNode.Spec.Taints) + suite.T().Logf("labels %#v, taints %#v", k8sNode.Labels, k8sNode.Spec.Taints) for k, v := range expectedLabels { if v == "" { @@ -175,7 +177,7 @@ outer: } if k8sNode.Labels[k] != v { - suite.T().Logf("label %q is not %q", k, v) + suite.T().Logf("label %q is %q but expected %q", k, k8sNode.Labels[k], v) continue outer } diff --git a/internal/integration/api/node-taints.go b/internal/integration/api/node-taints.go new file mode 100644 index 000000000..b66c7fc30 --- /dev/null +++ b/internal/integration/api/node-taints.go @@ -0,0 +1,189 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build integration_api + +package api + +import ( + "context" + "slices" + "strings" + "time" + + "github.com/siderolabs/gen/maps" + "github.com/siderolabs/gen/xslices" + "github.com/siderolabs/gen/xtesting/must" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + + "github.com/siderolabs/talos/internal/integration/base" + machineapi "github.com/siderolabs/talos/pkg/machinery/api/machine" + "github.com/siderolabs/talos/pkg/machinery/client" + "github.com/siderolabs/talos/pkg/machinery/config/container" + "github.com/siderolabs/talos/pkg/machinery/config/machine" + "github.com/siderolabs/talos/pkg/machinery/constants" +) + +// NodeTaintsSuite verifies updating node taints via machine config. +type NodeTaintsSuite struct { + base.K8sSuite + + ctx context.Context //nolint:containedctx + ctxCancel context.CancelFunc +} + +// SuiteName ... +func (suite *NodeTaintsSuite) SuiteName() string { + return "api.NodeTaintsSuite" +} + +// SetupTest ... +func (suite *NodeTaintsSuite) SetupTest() { + // make sure API calls have timeout + suite.ctx, suite.ctxCancel = context.WithTimeout(context.Background(), 5*time.Minute) +} + +// TearDownTest ... +func (suite *NodeTaintsSuite) TearDownTest() { + if suite.ctxCancel != nil { + suite.ctxCancel() + } +} + +// TestUpdateControlPlane verifies node taints updates on control plane nodes. +func (suite *NodeTaintsSuite) TestUpdateControlPlane() { + node := suite.RandomDiscoveredNodeInternalIP(machine.TypeControlPlane) + + suite.testUpdate(node) +} + +// testUpdate cycles through a set of node taints updates reverting the change in the end. +func (suite *NodeTaintsSuite) testUpdate(node string) { + k8sNode, err := suite.GetK8sNodeByInternalIP(suite.ctx, node) + suite.Require().NoError(err) + + suite.T().Logf("updating taints on node %q (%q)", node, k8sNode.Name) + + watcher, err := suite.Clientset.CoreV1().Nodes().Watch(suite.ctx, metav1.ListOptions{ + FieldSelector: metadataKeyName + k8sNode.Name, + Watch: true, + }) + suite.Require().NoError(err) + + defer watcher.Stop() + + // set two new taints + suite.setNodeTaints(node, map[string]string{ + "talos.dev/test1": "value1:NoSchedule", + "talos.dev/test2": "NoSchedule", + }) + + suite.waitUntil(watcher, map[string]string{ + constants.LabelNodeRoleControlPlane: "NoSchedule", + "talos.dev/test1": "value1:NoSchedule", + "talos.dev/test2": "NoSchedule", + }) + + // remove one taint + suite.setNodeTaints(node, map[string]string{ + "talos.dev/test1": "value1:NoSchedule", + }) + + suite.waitUntil(watcher, map[string]string{ + constants.LabelNodeRoleControlPlane: "NoSchedule", + "talos.dev/test1": "value1:NoSchedule", + }) + + // remove all taints + suite.setNodeTaints(node, nil) + + suite.waitUntil(watcher, map[string]string{ + constants.LabelNodeRoleControlPlane: "NoSchedule", + }) +} + +func (suite *NodeTaintsSuite) waitUntil(watcher watch.Interface, expectedTaints map[string]string) { +outer: + for { + select { + case ev := <-watcher.ResultChan(): + k8sNode, ok := ev.Object.(*v1.Node) + suite.Require().Truef(ok, "watch event is not of type v1.Node but was %T", ev.Object) + + suite.T().Logf("labels %#v, taints %#v", k8sNode.Labels, k8sNode.Spec.Taints) + + taints := xslices.ToMap(k8sNode.Spec.Taints, func(t v1.Taint) (string, string) { + switch { + case t.Value == "": + return t.Key, string(t.Effect) + case t.Effect == "": + return t.Key, t.Value + default: + return t.Key, strings.Join([]string{t.Value, string(t.Effect)}, ":") + } + }) + + expectedTaintsKeys := maps.Keys(expectedTaints) + + slices.Sort(expectedTaintsKeys) + + for _, key := range expectedTaintsKeys { + actualValue, ok := taints[key] + if !ok { + suite.T().Logf("taint %q is not present", key) + + continue outer + } + + expectedValue := expectedTaints[key] + + if expectedValue != actualValue { + suite.T().Logf("expected taint %q to be %q but was %q", key, expectedValue, actualValue) + + continue outer + } + + delete(taints, key) + } + + if len(taints) > 0 { + keys := maps.Keys(taints) + + slices.Sort(keys) + + suite.T().Logf("taints %v are still present", keys) + + continue outer + } + + return + case <-suite.ctx.Done(): + suite.T().Fatal("timeout") + } + } +} + +func (suite *NodeTaintsSuite) setNodeTaints(nodeIP string, nodeTaints map[string]string) { + nodeCtx := client.WithNode(suite.ctx, nodeIP) + + nodeConfig := must.Value(suite.ReadConfigFromNode(nodeCtx))(suite.T()) + + nodeConfigRaw := nodeConfig.RawV1Alpha1() + suite.Require().NotNil(nodeConfigRaw, "node config is not of type v1alpha1.Config") + + nodeConfigRaw.MachineConfig.MachineNodeTaints = nodeTaints + + bytes := must.Value(container.NewV1Alpha1(nodeConfigRaw).Bytes())(suite.T()) + + must.Value(suite.Client.ApplyConfiguration(nodeCtx, &machineapi.ApplyConfigurationRequest{ + Data: bytes, + Mode: machineapi.ApplyConfigurationRequest_NO_REBOOT, + }))(suite.T()) +} + +func init() { + allSuites = append(allSuites, new(NodeTaintsSuite)) +} diff --git a/pkg/machinery/config/config/machine.go b/pkg/machinery/config/config/machine.go index d785264ad..0d460cfef 100644 --- a/pkg/machinery/config/config/machine.go +++ b/pkg/machinery/config/config/machine.go @@ -42,6 +42,7 @@ type MachineConfig interface { Kernel() Kernel SeccompProfiles() []SeccompProfile NodeLabels() NodeLabels + NodeTaints() NodeTaints } // SeccompProfile defines the requirements for a config that pertains to seccomp @@ -54,6 +55,9 @@ type SeccompProfile interface { // NodeLabels defines the labels that should be set on a node. type NodeLabels map[string]string +// NodeTaints defines the taints that should be set on a node. +type NodeTaints map[string]string + // Disk represents the options available for partitioning, formatting, and // mounting extra disks. type Disk interface { diff --git a/pkg/machinery/config/container/container_test.go b/pkg/machinery/config/container/container_test.go index be831fb1d..f267ba3de 100644 --- a/pkg/machinery/config/container/container_test.go +++ b/pkg/machinery/config/container/container_test.go @@ -8,6 +8,7 @@ import ( "net/url" "testing" + "github.com/siderolabs/gen/xtesting/must" "github.com/siderolabs/go-pointer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,7 +35,7 @@ func TestNew(t *testing.T) { } sideroLinkCfg := siderolink.NewConfigV1Alpha1() - sideroLinkCfg.APIUrlConfig.URL = must(url.Parse("https://siderolink.api/join?jointoken=secret&user=alice")) + sideroLinkCfg.APIUrlConfig.URL = must.Value(url.Parse("https://siderolink.api/join?jointoken=secret&user=alice"))(t) cfg, err := container.New(v1alpha1Cfg, sideroLinkCfg) require.NoError(t, err) @@ -81,7 +82,7 @@ func TestValidate(t *testing.T) { t.Parallel() sideroLinkCfg := siderolink.NewConfigV1Alpha1() - sideroLinkCfg.APIUrlConfig.URL = must(url.Parse("https://siderolink.api/?jointoken=secret&user=alice")) + sideroLinkCfg.APIUrlConfig.URL = must.Value(url.Parse("https://siderolink.api/?jointoken=secret&user=alice"))(t) invalidSideroLinkCfg := siderolink.NewConfigV1Alpha1() @@ -89,7 +90,7 @@ func TestValidate(t *testing.T) { ClusterConfig: &v1alpha1.ClusterConfig{ ControlPlane: &v1alpha1.ControlPlaneConfig{ Endpoint: &v1alpha1.Endpoint{ - URL: must(url.Parse("https://localhost:6443")), + URL: must.Value(url.Parse("https://localhost:6443"))(t), }, }, }, @@ -159,14 +160,6 @@ func TestValidate(t *testing.T) { } } -func must[T any](t T, err error) T { - if err != nil { - panic(err) - } - - return t -} - type validationMode struct{} func (validationMode) String() string { diff --git a/pkg/machinery/config/types/v1alpha1/schemas/v1alpha1_config.schema.json b/pkg/machinery/config/types/v1alpha1/schemas/v1alpha1_config.schema.json index d4c30de70..7a3336f06 100644 --- a/pkg/machinery/config/types/v1alpha1/schemas/v1alpha1_config.schema.json +++ b/pkg/machinery/config/types/v1alpha1/schemas/v1alpha1_config.schema.json @@ -2096,6 +2096,18 @@ "description": "Configures the node labels for the machine.\n", "markdownDescription": "Configures the node labels for the machine.", "x-intellij-html-description": "\u003cp\u003eConfigures the node labels for the machine.\u003c/p\u003e\n" + }, + "nodeTaints": { + "patternProperties": { + ".*": { + "type": "string" + } + }, + "type": "object", + "title": "nodeTaints", + "description": "Configures the node taints for the machine. Effect is optional.\n", + "markdownDescription": "Configures the node taints for the machine. Effect is optional.", + "x-intellij-html-description": "\u003cp\u003eConfigures the node taints for the machine. Effect is optional.\u003c/p\u003e\n" } }, "additionalProperties": false, diff --git a/pkg/machinery/config/types/v1alpha1/v1alpha1_provider.go b/pkg/machinery/config/types/v1alpha1/v1alpha1_provider.go index b905c3c2c..a8886ead7 100644 --- a/pkg/machinery/config/types/v1alpha1/v1alpha1_provider.go +++ b/pkg/machinery/config/types/v1alpha1/v1alpha1_provider.go @@ -90,6 +90,11 @@ func (m *MachineConfig) NodeLabels() config.NodeLabels { return m.MachineNodeLabels } +// NodeTaints implements the config.Provider interface. +func (m *MachineConfig) NodeTaints() config.NodeTaints { + return m.MachineNodeTaints +} + // Cluster implements the config.Provider interface. func (c *Config) Cluster() config.ClusterConfig { if c == nil || c.ClusterConfig == nil { diff --git a/pkg/machinery/config/types/v1alpha1/v1alpha1_types.go b/pkg/machinery/config/types/v1alpha1/v1alpha1_types.go index 5c360e2ba..4af947eb5 100644 --- a/pkg/machinery/config/types/v1alpha1/v1alpha1_types.go +++ b/pkg/machinery/config/types/v1alpha1/v1alpha1_types.go @@ -281,6 +281,12 @@ type MachineConfig struct { // - name: node labels example. // value: 'map[string]string{"exampleLabel": "exampleLabelValue"}' MachineNodeLabels map[string]string `yaml:"nodeLabels,omitempty"` + // description: | + // Configures the node taints for the machine. Effect is optional. + // examples: + // - name: node taints example. + // value: 'map[string]string{"exampleTaint": "exampleTaintValue:NoSchedule"}' + MachineNodeTaints map[string]string `yaml:"nodeTaints,omitempty"` } // MachineSeccompProfile defines seccomp profiles for the machine. diff --git a/pkg/machinery/config/types/v1alpha1/v1alpha1_types_doc.go b/pkg/machinery/config/types/v1alpha1/v1alpha1_types_doc.go index 4cc979cd4..9c6e48731 100644 --- a/pkg/machinery/config/types/v1alpha1/v1alpha1_types_doc.go +++ b/pkg/machinery/config/types/v1alpha1/v1alpha1_types_doc.go @@ -255,6 +255,13 @@ func (MachineConfig) Doc() *encoder.Doc { Description: "Configures the node labels for the machine.", Comments: [3]string{"" /* encoder.HeadComment */, "Configures the node labels for the machine." /* encoder.LineComment */, "" /* encoder.FootComment */}, }, + { + Name: "nodeTaints", + Type: "map[string]string", + Note: "", + Description: "Configures the node taints for the machine. Effect is optional.", + Comments: [3]string{"" /* encoder.HeadComment */, "Configures the node taints for the machine. Effect is optional." /* encoder.LineComment */, "" /* encoder.FootComment */}, + }, }, } @@ -284,6 +291,7 @@ func (MachineConfig) Doc() *encoder.Doc { doc.Fields[20].AddExample("", machineKernelExample()) doc.Fields[21].AddExample("", machineSeccompExample()) doc.Fields[22].AddExample("node labels example.", map[string]string{"exampleLabel": "exampleLabelValue"}) + doc.Fields[23].AddExample("node taints example.", map[string]string{"exampleTaint": "exampleTaintValue:NoSchedule"}) return doc } diff --git a/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go b/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go index 90b123a0d..910caef0d 100644 --- a/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go +++ b/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go @@ -286,6 +286,10 @@ func (c *Config) Validate(mode validation.RuntimeMode, options ...validation.Opt result = multierror.Append(result, fmt.Errorf("invalid machine node labels: %w", err)) } + if err := labels.ValidateTaints(c.MachineConfig.MachineNodeTaints); err != nil { + result = multierror.Append(result, fmt.Errorf("invalid machine node taints: %w", err)) + } + if c.Machine().Features().KubernetesTalosAPIAccess().Enabled() { if !c.Machine().Features().RBACEnabled() { result = multierror.Append(result, fmt.Errorf("feature API RBAC should be enabled when Kubernetes Talos API Access feature is enabled")) diff --git a/pkg/machinery/config/types/v1alpha1/zz_generated.deepcopy.go b/pkg/machinery/config/types/v1alpha1/zz_generated.deepcopy.go index 26a8858fa..36203387d 100644 --- a/pkg/machinery/config/types/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/machinery/config/types/v1alpha1/zz_generated.deepcopy.go @@ -1600,6 +1600,13 @@ func (in *MachineConfig) DeepCopyInto(out *MachineConfig) { (*out)[key] = val } } + if in.MachineNodeTaints != nil { + in, out := &in.MachineNodeTaints, &out.MachineNodeTaints + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } return } diff --git a/pkg/machinery/constants/constants.go b/pkg/machinery/constants/constants.go index b61466ba2..42c472bcd 100644 --- a/pkg/machinery/constants/constants.go +++ b/pkg/machinery/constants/constants.go @@ -1005,6 +1005,13 @@ var UdevdDroppedCapabilities = map[string]struct{}{ "cap_sys_boot": {}, } +// ValidEffects is the set of valid taint effects. +var ValidEffects = []string{ + "NoSchedule", + "PreferNoSchedule", + "NoExecute", +} + // OSReleaseTemplate is the template for /etc/os-release. const OSReleaseTemplate = `NAME="{{ .Name }}" ID={{ .ID }} diff --git a/pkg/machinery/labels/validate.go b/pkg/machinery/labels/validate.go index 51e9d9ca7..ab81e1096 100644 --- a/pkg/machinery/labels/validate.go +++ b/pkg/machinery/labels/validate.go @@ -10,11 +10,13 @@ package labels import ( "fmt" "regexp" - "sort" + "slices" "strings" "github.com/hashicorp/go-multierror" "github.com/siderolabs/gen/maps" + + "github.com/siderolabs/talos/pkg/machinery/constants" ) // Validate validates that a set of labels are correctly defined. @@ -22,7 +24,7 @@ func Validate(labels map[string]string) error { var multiErr *multierror.Error keys := maps.Keys(labels) - sort.Strings(keys) + slices.Sort(keys) for _, k := range keys { if err := ValidateQualifiedName(k); err != nil { @@ -130,3 +132,41 @@ func ValidateLabelValue(value string) error { return nil } + +// ValidateTaints validates that a set of taints is correctly defined. +func ValidateTaints(taints map[string]string) error { + var multiErr *multierror.Error + + keys := maps.Keys(taints) + slices.Sort(keys) + + for _, k := range keys { + if err := ValidateQualifiedName(k); err != nil { + multiErr = multierror.Append(multiErr, err) + + continue + } + + val, effect, found := strings.Cut(taints[k], ":") + if !found { + effect = val + } + + // validate that the taint has a valid effect, which is required to add the taint + if !slices.Contains(constants.ValidEffects, effect) { + multiErr = multierror.Append(multiErr, fmt.Errorf("invalid taint effect: %q", effect)) + + continue + } + + if found { + if err := ValidateLabelValue(val); err != nil { + multiErr = multierror.Append(multiErr, err) + + continue + } + } + } + + return multiErr.ErrorOrNil() +} diff --git a/pkg/machinery/labels/validate_test.go b/pkg/machinery/labels/validate_test.go index 0d9833dd1..2abeb63e3 100644 --- a/pkg/machinery/labels/validate_test.go +++ b/pkg/machinery/labels/validate_test.go @@ -55,3 +55,44 @@ func TestValidate(t *testing.T) { }) } } + +func TestValidateTaints(t *testing.T) { + for _, tt := range []struct { + name string + taints map[string]string + + expectedError string + }{ + { + name: "empty", + }, + { + name: "valid", + taints: map[string]string{ + "foor": "bar:NoExecute", + "doo": "NoExecute", + }, + }, + { + name: "invalid", + taints: map[string]string{ + strings.Repeat("a", 64): "bar", + "bar": strings.Repeat("a", 64), + "foo": "bar:NoExecute:NoSchedule", + "loo": "bar:", + "zoo": "bar:NoExocute", + "koo": "key", + }, + expectedError: "6 errors occurred:\n\t* name is too long: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" (limit is 63)\n\t* invalid taint effect: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\t* invalid taint effect: \"NoExecute:NoSchedule\"\n\t* invalid taint effect: \"key\"\n\t* invalid taint effect: \"\"\n\t* invalid taint effect: \"NoExocute\"\n\n", //nolint:lll + }, + } { + t.Run(tt.name, func(t *testing.T) { + err := labels.ValidateTaints(tt.taints) + if tt.expectedError != "" { + assert.EqualError(t, err, tt.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/website/content/v1.6/reference/configuration.md b/website/content/v1.6/reference/configuration.md index 25259fcc9..54eaa4b3e 100644 --- a/website/content/v1.6/reference/configuration.md +++ b/website/content/v1.6/reference/configuration.md @@ -433,6 +433,10 @@ seccompProfiles: nodeLabels: exampleLabel: exampleLabelValue {{< /highlight >}} | | +|`nodeTaints` |map[string]string |Configures the node taints for the machine. Effect is optional.
Show example(s){{< highlight yaml >}} +nodeTaints: + exampleTaint: exampleTaintValue:NoSchedule +{{< /highlight >}}
| | diff --git a/website/content/v1.6/schemas/v1alpha1_config.schema.json b/website/content/v1.6/schemas/v1alpha1_config.schema.json index d4c30de70..7a3336f06 100644 --- a/website/content/v1.6/schemas/v1alpha1_config.schema.json +++ b/website/content/v1.6/schemas/v1alpha1_config.schema.json @@ -2096,6 +2096,18 @@ "description": "Configures the node labels for the machine.\n", "markdownDescription": "Configures the node labels for the machine.", "x-intellij-html-description": "\u003cp\u003eConfigures the node labels for the machine.\u003c/p\u003e\n" + }, + "nodeTaints": { + "patternProperties": { + ".*": { + "type": "string" + } + }, + "type": "object", + "title": "nodeTaints", + "description": "Configures the node taints for the machine. Effect is optional.\n", + "markdownDescription": "Configures the node taints for the machine. Effect is optional.", + "x-intellij-html-description": "\u003cp\u003eConfigures the node taints for the machine. Effect is optional.\u003c/p\u003e\n" } }, "additionalProperties": false,