Skip to content

Commit

Permalink
feat: publish installed extensions as node labels/annotations
Browse files Browse the repository at this point in the history
Extensions are posted the following way:

`extensions.talos.dev/<name>=<version>`

The name should be valid as a label (annotation) key.

If the value is valid as a label value, use labels, otherwise use
annotations.

Also implements node annotations in the machine config as a side-effect.

Fixes #9089

Fixes #8971

See #9070

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
  • Loading branch information
smira committed Aug 1, 2024
1 parent 3f2058a commit 7a1c62b
Show file tree
Hide file tree
Showing 33 changed files with 1,419 additions and 277 deletions.
6 changes: 6 additions & 0 deletions api/resource/definitions/k8s/k8s.proto
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ message ManifestStatusSpec {
repeated string manifests_applied = 1;
}

// NodeAnnotationSpecSpec represents an annoation that's attached to a Talos node.
message NodeAnnotationSpecSpec {
string key = 1;
string value = 2;
}

// NodeIPConfigSpec holds the Node IP specification.
message NodeIPConfigSpec {
repeated string valid_subnets = 1;
Expand Down
19 changes: 19 additions & 0 deletions hack/release.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ Talos Linux on config generation now adds a label `node.kubernetes.io/exclude-fr
title = "Secure Boot"
description = """\
Talos Linux now can optionally include well-known UEFI (Microsoft) SecureBoot keys into the auto-enrollment UEFI database.
"""

[notes.annotations]
title = "Node Annotations"
description = """\
Talos Linux now supports configuring Kubernetes node annotations via machine configuration (`.machine.nodeAnnotations`) in a way similar to node labels.
"""

[notes.kubelet]
title = "Extensions in Kubernetes Nodes"
description = """\
Talos Linux now publishes list of installed extensions as Kubernetes node labels/annotations.
The key format is `extensions.talos.dev/<name>` and the value is the extension version.
If the extension name is not valid as a label key, it will be skipped.
If the extension version is a valid label value, it will be put to the label; otherwise it will be put to the annotation.
For Talos machines booted of the Image Factory artifacts, this means that the schematic ID will be published as the annotation
`extensions.talos.dev/schematic` (as it is longer than 63 characters).
"""

[make_deps]
Expand Down
108 changes: 108 additions & 0 deletions internal/app/machined/pkg/controllers/k8s/node_annotation_spec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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/.

package k8s

import (
"context"
"fmt"

"github.com/cosi-project/runtime/pkg/controller"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/cosi-project/runtime/pkg/state"
"github.com/siderolabs/gen/optional"
"go.uber.org/zap"

"github.com/siderolabs/talos/pkg/machinery/labels"
"github.com/siderolabs/talos/pkg/machinery/resources/config"
"github.com/siderolabs/talos/pkg/machinery/resources/k8s"
"github.com/siderolabs/talos/pkg/machinery/resources/runtime"
)

// NodeAnnotationSpecController manages k8s.NodeAnnotationsConfig based on configuration.
type NodeAnnotationSpecController struct{}

// Name implements controller.Controller interface.
func (ctrl *NodeAnnotationSpecController) Name() string {
return "k8s.NodeAnnotationSpecController"
}

// Inputs implements controller.Controller interface.
func (ctrl *NodeAnnotationSpecController) Inputs() []controller.Input {
return []controller.Input{
{
Namespace: config.NamespaceName,
Type: config.MachineConfigType,
ID: optional.Some(config.V1Alpha1ID),
Kind: controller.InputWeak,
},
{
Namespace: runtime.NamespaceName,
Type: runtime.ExtensionStatusType,
Kind: controller.InputWeak,
},
}
}

// Outputs implements controller.Controller interface.
func (ctrl *NodeAnnotationSpecController) Outputs() []controller.Output {
return []controller.Output{
{
Type: k8s.NodeAnnotationSpecType,
Kind: controller.OutputExclusive,
},
}
}

// Run implements controller.Controller interface.
//
//nolint:gocyclo
func (ctrl *NodeAnnotationSpecController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) error {
for {
select {
case <-ctx.Done():
return nil
case <-r.EventCh():
}

cfg, err := safe.ReaderGetByID[*config.MachineConfig](ctx, r, config.V1Alpha1ID)
if err != nil && !state.IsNotFoundError(err) {
return fmt.Errorf("error getting config: %w", err)
}

r.StartTrackingOutputs()

nodeAnnotations := map[string]string{}

if cfg != nil && cfg.Config().Machine() != nil {
for k, v := range cfg.Config().Machine().NodeAnnotations() {
nodeAnnotations[k] = v
}
}

if err = extensionsToNodeKV(
ctx, r, nodeAnnotations,
func(annotationValue string) bool {
return labels.ValidateLabelValue(annotationValue) != nil
},
); err != nil {
return fmt.Errorf("error converting extensions to node annotations: %w", err)
}

for key, value := range nodeAnnotations {
if err = safe.WriterModify(ctx, r, k8s.NewNodeAnnotationSpec(key), func(k *k8s.NodeAnnotationSpec) error {
k.TypedSpec().Key = key
k.TypedSpec().Value = value

return nil
}); err != nil {
return fmt.Errorf("error updating node label spec: %w", err)
}
}

if err = safe.CleanupOutputs[*k8s.NodeAnnotationSpec](ctx, r); err != nil {
return err
}
}
}
122 changes: 122 additions & 0 deletions internal/app/machined/pkg/controllers/k8s/node_annotation_spec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// 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/.

package k8s_test

import (
"testing"
"time"

"github.com/cosi-project/runtime/pkg/resource/rtestutils"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/cosi-project/runtime/pkg/state"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"

"github.com/siderolabs/talos/internal/app/machined/pkg/controllers/ctest"
k8sctrl "github.com/siderolabs/talos/internal/app/machined/pkg/controllers/k8s"
"github.com/siderolabs/talos/pkg/machinery/config/container"
"github.com/siderolabs/talos/pkg/machinery/config/types/v1alpha1"
"github.com/siderolabs/talos/pkg/machinery/extensions"
"github.com/siderolabs/talos/pkg/machinery/resources/config"
"github.com/siderolabs/talos/pkg/machinery/resources/k8s"
"github.com/siderolabs/talos/pkg/machinery/resources/runtime"
)

type NodeAnnotationsSuite struct {
ctest.DefaultSuite
}

func TestNodeAnnotationsSuite(t *testing.T) {
t.Parallel()

suite.Run(t, &NodeAnnotationsSuite{
DefaultSuite: ctest.DefaultSuite{
Timeout: 5 * time.Second,
AfterSetup: func(s *ctest.DefaultSuite) {
s.Require().NoError(s.Runtime().RegisterController(&k8sctrl.NodeAnnotationSpecController{}))
},
},
})
}

func (suite *NodeAnnotationsSuite) updateMachineConfig(annotations map[string]string) {
cfg, err := safe.StateGetByID[*config.MachineConfig](suite.Ctx(), suite.State(), config.V1Alpha1ID)
if err != nil && !state.IsNotFoundError(err) {
suite.Require().NoError(err)
}

if cfg == nil {
cfg = config.NewMachineConfig(container.NewV1Alpha1(&v1alpha1.Config{
MachineConfig: &v1alpha1.MachineConfig{
MachineType: "controlplane",
MachineNodeAnnotations: annotations,
},
}))

suite.Require().NoError(suite.State().Create(suite.Ctx(), cfg))
} else {
cfg.Container().RawV1Alpha1().MachineConfig.MachineNodeAnnotations = annotations
suite.Require().NoError(suite.State().Update(suite.Ctx(), cfg))
}
}

func (suite *NodeAnnotationsSuite) TestChangeLabel() {
// given
expectedAnnotation := "some/annotation"
oldValue := "oldValue"
expectedValue := "newValue"

// when
suite.updateMachineConfig(map[string]string{
expectedAnnotation: oldValue,
})

rtestutils.AssertResources(suite.Ctx(), suite.T(), suite.State(), []string{expectedAnnotation},
func(labelSpec *k8s.NodeAnnotationSpec, asrt *assert.Assertions) {
asrt.Equal(oldValue, labelSpec.TypedSpec().Value)
})

suite.updateMachineConfig(map[string]string{
expectedAnnotation: expectedValue,
})

// then
rtestutils.AssertResources(suite.Ctx(), suite.T(), suite.State(), []string{expectedAnnotation},
func(labelSpec *k8s.NodeAnnotationSpec, asrt *assert.Assertions) {
asrt.Equal(expectedValue, labelSpec.TypedSpec().Value)
})
}

func (suite *NodeAnnotationsSuite) TestExtensionAnnotations() {
ext1 := runtime.NewExtensionStatus(runtime.NamespaceName, "0")
ext1.TypedSpec().Metadata = extensions.Metadata{
Name: "zfs",
Version: "2.2.4",
}

ext2 := runtime.NewExtensionStatus(runtime.NamespaceName, "1")
ext2.TypedSpec().Metadata = extensions.Metadata{
Name: "drbd",
Version: "9.2.8-v1.7.5",
}

ext3 := runtime.NewExtensionStatus(runtime.NamespaceName, "2")
ext3.TypedSpec().Metadata = extensions.Metadata{
Name: "schematic",
Version: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
}

suite.Require().NoError(suite.State().Create(suite.Ctx(), ext1))
suite.Require().NoError(suite.State().Create(suite.Ctx(), ext2))
suite.Require().NoError(suite.State().Create(suite.Ctx(), ext3))

rtestutils.AssertNoResource[*k8s.NodeAnnotationSpec](suite.Ctx(), suite.T(), suite.State(), "extensions.talos.dev/zfs")
rtestutils.AssertNoResource[*k8s.NodeAnnotationSpec](suite.Ctx(), suite.T(), suite.State(), "extensions.talos.dev/drbd")

rtestutils.AssertResources(suite.Ctx(), suite.T(), suite.State(), []string{"extensions.talos.dev/schematic"},
func(labelSpec *k8s.NodeAnnotationSpec, asrt *assert.Assertions) {
asrt.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", labelSpec.TypedSpec().Value)
})
}
Loading

0 comments on commit 7a1c62b

Please sign in to comment.