Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: udevd rules controller #7164

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ issues:
- path: internal/app/machined/pkg/system/services
linters:
- dupl
- path: internal/app/machined/pkg/controllers/files
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

golangcilint seems really weird now, maybe it;s my cache

linters:
- dupl
- path: resources/files
linters:
- dupl
- path: cmd/installer/pkg/qemuimg
text: "should have a package comment"
linters:
Expand Down
10 changes: 10 additions & 0 deletions api/resource/definitions/files/files.proto
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,13 @@ message EtcFileStatusSpec {
string spec_version = 1;
}

// UdevRuleSpec is the specification for UdevRule resource.
message UdevRuleSpec {
string rule = 1;
}

// UdevRuleStatusSpec is the specification for UdevRule resource.
message UdevRuleStatusSpec {
bool active = 1;
}

35 changes: 6 additions & 29 deletions internal/app/machined/pkg/controllers/cri/seccomp_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package cri_test

import (
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -84,29 +85,6 @@ func (suite *CRISeccompProfileSuite) TestReconcileSeccompProfile() {
})
}

suite.AssertWithin(1*time.Second, 100*time.Millisecond, func() error {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was already handled in the test case above

seccompProfile, err := ctest.Get[*criseccompresource.SeccompProfile](
suite,
criseccompresource.NewSeccompProfile("audit.json").Metadata(),
)
if err != nil {
if state.IsNotFoundError(err) {
return retry.ExpectedError(err)
}

return err
}

spec := seccompProfile.TypedSpec()

suite.Assert().Equal("audit.json", spec.Name)
suite.Assert().Equal(map[string]interface{}{
"defaultAction": "SCMP_ACT_LOG",
}, spec.Value)

return nil
})

// test deletion
cfg = config.NewMachineConfig(&v1alpha1.Config{
MachineConfig: &v1alpha1.MachineConfig{
Expand All @@ -123,24 +101,23 @@ func (suite *CRISeccompProfileSuite) TestReconcileSeccompProfile() {
},
})

ctest.UpdateWithConflicts(suite, cfg, func(mc *config.MachineConfig) error {
return nil
})
cfg.Metadata().SetVersion(cfg.Metadata().Version().Next())
suite.Require().NoError(suite.State().Update(suite.Ctx(), cfg))

suite.AssertWithin(1*time.Second, 100*time.Millisecond, func() error {
_, err := ctest.Get[*criseccompresource.SeccompProfile](
suite,
criseccompresource.NewSeccompProfile("deny.json").Metadata(),
)
if err != nil {
if !state.IsNotFoundError(err) {
return err
if state.IsNotFoundError(err) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test was always broken 😅

return nil
}

return err
}

return nil
return retry.ExpectedError(fmt.Errorf("seccomp profile with id deny.json should not exist"))
})
}

Expand Down
100 changes: 27 additions & 73 deletions internal/app/machined/pkg/controllers/files/etcfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,76 +5,49 @@
package files_test

import (
"context"
"log"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"time"

"github.com/cosi-project/runtime/pkg/controller/runtime"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/state"
"github.com/cosi-project/runtime/pkg/state/impl/inmem"
"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
"github.com/siderolabs/go-retry/retry"
"github.com/stretchr/testify/suite"

"github.com/siderolabs/talos/internal/app/machined/pkg/controllers/ctest"
filesctrl "github.com/siderolabs/talos/internal/app/machined/pkg/controllers/files"
"github.com/siderolabs/talos/pkg/logging"
"github.com/siderolabs/talos/pkg/machinery/resources/files"
)

type EtcFileSuite struct {
suite.Suite

state state.State

runtime *runtime.Runtime
wg sync.WaitGroup

ctx context.Context //nolint:containedctx
ctxCancel context.CancelFunc

ctest.DefaultSuite
etcPath string
shadowPath string
}

func (suite *EtcFileSuite) SetupTest() {
suite.ctx, suite.ctxCancel = context.WithTimeout(context.Background(), 3*time.Minute)

suite.state = state.WrapCore(namespaced.NewState(inmem.Build))

var err error

suite.runtime, err = runtime.NewRuntime(suite.state, logging.Wrap(log.Writer()))
suite.Require().NoError(err)

suite.startRuntime()
func TestEtcFileSuite(t *testing.T) {
// skip test if we are not root
if os.Getuid() != 0 {
t.Skip("can't run the test as non-root")
}

suite.etcPath = suite.T().TempDir()
suite.shadowPath = suite.T().TempDir()
etcTempPath := t.TempDir()
shadowTempPath := t.TempDir()

suite.Require().NoError(
suite.runtime.RegisterController(
&filesctrl.EtcFileController{
EtcPath: suite.etcPath,
ShadowPath: suite.shadowPath,
suite.Run(t, &EtcFileSuite{
DefaultSuite: ctest.DefaultSuite{
AfterSetup: func(suite *ctest.DefaultSuite) {
suite.Require().NoError(suite.Runtime().RegisterController(&filesctrl.EtcFileController{
EtcPath: etcTempPath,
ShadowPath: shadowTempPath,
}))
},
),
)
}

func (suite *EtcFileSuite) startRuntime() {
suite.wg.Add(1)

go func() {
defer suite.wg.Done()

suite.Assert().NoError(suite.runtime.Run(suite.ctx))
}()
},
etcPath: etcTempPath,
shadowPath: shadowTempPath,
})
}

func (suite *EtcFileSuite) assertEtcFile(filename, contents string, expectedVersion resource.Version) error {
Expand All @@ -87,8 +60,8 @@ func (suite *EtcFileSuite) assertEtcFile(filename, contents string, expectedVers
return retry.ExpectedErrorf("contents don't match %q != %q", string(b), contents)
}

r, err := suite.state.Get(
suite.ctx,
r, err := suite.State().Get(
suite.Ctx(),
resource.NewMetadata(files.NamespaceName, files.EtcFileStatusType, filename, resource.VersionUndefined),
)
if err != nil {
Expand Down Expand Up @@ -123,19 +96,15 @@ func (suite *EtcFileSuite) TestFiles() {
suite.T().Logf("mock created %q", filepath.Join(suite.etcPath, etcFileSpec.Metadata().ID()))
suite.Require().NoError(os.WriteFile(filepath.Join(suite.etcPath, etcFileSpec.Metadata().ID()), nil, 0o644))

suite.Require().NoError(suite.state.Create(suite.ctx, etcFileSpec))
suite.Require().NoError(suite.State().Create(suite.Ctx(), etcFileSpec))

suite.Assert().NoError(
retry.Constant(5*time.Second, retry.WithUnits(100*time.Millisecond)).Retry(
func() error {
return suite.assertEtcFile("test1", "foo", etcFileSpec.Metadata().Version())
},
),
)
suite.AssertWithin(5*time.Second, 100*time.Millisecond, func() error {
return suite.assertEtcFile("test1", "foo", etcFileSpec.Metadata().Version())
})

for _, r := range []resource.Resource{etcFileSpec} {
for {
ready, err := suite.state.Teardown(suite.ctx, r.Metadata())
ready, err := suite.State().Teardown(suite.Ctx(), r.Metadata())
suite.Require().NoError(err)

if ready {
Expand All @@ -146,18 +115,3 @@ func (suite *EtcFileSuite) TestFiles() {
}
}
}

func (suite *EtcFileSuite) TearDownTest() {
suite.T().Log("tear down")

suite.ctxCancel()

suite.wg.Wait()

// trigger updates in resources to stop watch loops
suite.Assert().NoError(suite.state.Create(context.Background(), files.NewEtcFileSpec(files.NamespaceName, "bar")))
}

func TestEtcFileSuite(t *testing.T) {
suite.Run(t, new(EtcFileSuite))
}
120 changes: 120 additions & 0 deletions internal/app/machined/pkg/controllers/files/udev_rule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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 files

import (
"context"
"crypto/sha256"
"fmt"
"strings"

"github.com/cosi-project/runtime/pkg/controller"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/cosi-project/runtime/pkg/state"
"github.com/martinlindhe/base36"
"github.com/siderolabs/go-pointer"
"go.uber.org/zap"

"github.com/siderolabs/talos/pkg/machinery/resources/config"
"github.com/siderolabs/talos/pkg/machinery/resources/files"
)

// UdevRuleController is a controller that generates udev rules.
type UdevRuleController struct{}

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

// Inputs implements controller.Controller interface.
func (ctrl *UdevRuleController) Inputs() []controller.Input {
return []controller.Input{
{
Namespace: config.NamespaceName,
Type: config.MachineConfigType,
ID: pointer.To(config.V1Alpha1ID),
Kind: controller.InputWeak,
},
}
}

// Outputs implements controller.Controller interface.
func (ctrl *UdevRuleController) Outputs() []controller.Output {
return []controller.Output{
{
Type: files.UdevRuleType,
Kind: controller.OutputExclusive,
},
}
}

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

cfg, err := safe.ReaderGet[*config.MachineConfig](ctx, r, resource.NewMetadata(config.NamespaceName, config.MachineConfigType, config.V1Alpha1ID, resource.VersionUndefined))
if err != nil {
if state.IsNotFoundError(err) {
continue
}

return fmt.Errorf("error getting config: %w", err)
}

touchedIDs := make(map[string]struct{}, len(cfg.Config().Machine().Udev().Rules()))

for _, rule := range cfg.Config().Machine().Udev().Rules() {
ruleID := ctrl.generateRuleHash(rule)

if err = safe.WriterModify(ctx, r, files.NewUdevRule(ruleID), func(udevRule *files.UdevRule) error {
udevRule.TypedSpec().Rule = rule

return nil
}); err != nil {
return err
}

touchedIDs[ruleID] = struct{}{}
}

// list keys for cleanup
list, err := safe.ReaderList[*files.UdevRule](ctx, r, resource.NewMetadata(files.NamespaceName, files.UdevRuleType, "", resource.VersionUndefined))
if err != nil {
return fmt.Errorf("error listing udev rules: %w", err)
}

for iter := safe.IteratorFromList(list); iter.Next(); {
rule := iter.Value()

if _, ok := touchedIDs[rule.Metadata().ID()]; !ok {
if err := r.Destroy(ctx, rule.Metadata()); err != nil {
return fmt.Errorf("error deleting udev rule %s: %w", rule.Metadata().ID(), err)
}
}
}

r.ResetRestartBackoff()
}
}

func (ctrl *UdevRuleController) generateRuleHash(rule string) string {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is done since we don't take in rule name from machine config

h := sha256.New()
h.Write([]byte(rule))

hashBytes := h.Sum(nil)

b36 := strings.ToLower(base36.EncodeBytes(hashBytes))

return b36[:8]
}
Loading