Skip to content

Commit

Permalink
feat: implement EtcFileController to render files in /etc
Browse files Browse the repository at this point in the history
This implements two controllers: one which generates templates for
`/etc/hosts` and `/etc/resolv.config`, and another generic controller
which renders files to `/etc` via shadow bind mounts.

Signed-off-by: Andrey Smirnov <smirnov.andrey@gmail.com>
  • Loading branch information
smira authored and talos-bot committed Jun 7, 2021
1 parent 5aede1a commit e74f789
Show file tree
Hide file tree
Showing 11 changed files with 1,005 additions and 1 deletion.
186 changes: 186 additions & 0 deletions internal/app/machined/pkg/controllers/files/etcfile.go
@@ -0,0 +1,186 @@
// 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"
"errors"
"fmt"
"os"
"path/filepath"

"github.com/cosi-project/runtime/pkg/controller"
"github.com/cosi-project/runtime/pkg/resource"
"go.uber.org/zap"
"golang.org/x/sys/unix"

"github.com/talos-systems/talos/pkg/resources/files"
)

// EtcFileController watches EtcFileSpecs, creates/updates files.
type EtcFileController struct {
// Path to /etc directory, read-only filesystem.
EtcPath string
// Shadow path where actual file will be created and bind mounted into EtcdPath.
ShadowPath string

// Cache of bind mounts created.
bindMounts map[string]interface{}
}

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

// Inputs implements controller.Controller interface.
func (ctrl *EtcFileController) Inputs() []controller.Input {
return []controller.Input{
{
Namespace: files.NamespaceName,
Type: files.EtcFileSpecType,
Kind: controller.InputStrong,
},
}
}

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

// Run implements controller.Controller interface.
//
//nolint:gocyclo,cyclop
func (ctrl *EtcFileController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) error {
if ctrl.bindMounts == nil {
ctrl.bindMounts = make(map[string]interface{})
}

for {
select {
case <-ctx.Done():
return nil
case <-r.EventCh():
}

list, err := r.List(ctx, resource.NewMetadata(files.NamespaceName, files.EtcFileSpecType, "", resource.VersionUndefined))
if err != nil {
return fmt.Errorf("error listing specs: %w", err)
}

// add finalizers for all live resources
for _, res := range list.Items {
if res.Metadata().Phase() != resource.PhaseRunning {
continue
}

if err = r.AddFinalizer(ctx, res.Metadata(), ctrl.Name()); err != nil {
return fmt.Errorf("error adding finalizer: %w", err)
}
}

touchedIDs := make(map[resource.ID]struct{})

for _, item := range list.Items {
spec := item.(*files.EtcFileSpec) //nolint:errcheck,forcetypeassert
filename := spec.Metadata().ID()
_, mountExists := ctrl.bindMounts[filename]

src := filepath.Join(ctrl.ShadowPath, filename)
dst := filepath.Join(ctrl.EtcPath, filename)

switch spec.Metadata().Phase() {
case resource.PhaseTearingDown:
if mountExists {
logger.Debug("removing bind mount", zap.String("src", src), zap.String("dst", dst))

if err = unix.Unmount(dst, 0); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to unmount bind mount %q: %w", dst, err)
}

delete(ctrl.bindMounts, filename)
}

logger.Debug("removing file", zap.String("src", src))

if err = os.Remove(src); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove %q: %w", src, err)
}

// now remove finalizer as the link was deleted
if err = r.RemoveFinalizer(ctx, spec.Metadata(), ctrl.Name()); err != nil {
return fmt.Errorf("error removing finalizer: %w", err)
}
case resource.PhaseRunning:
if !mountExists {
logger.Debug("creating bind mount", zap.String("src", src), zap.String("dst", dst))

if err = createBindMount(src, dst); err != nil {
return fmt.Errorf("failed to create shadow bind mount %q -> %q: %w", src, dst, err)
}

ctrl.bindMounts[filename] = struct{}{}
}

logger.Debug("writing file contents", zap.String("dst", dst), zap.Stringer("version", spec.Metadata().Version()))

if err = os.WriteFile(dst, spec.TypedSpec().Contents, spec.TypedSpec().Mode); err != nil {
return fmt.Errorf("error updating %q: %w", dst, err)
}

if err = r.Modify(ctx, files.NewEtcFileStatus(files.NamespaceName, filename), func(r resource.Resource) error {
r.(*files.EtcFileStatus).TypedSpec().SpecVersion = spec.Metadata().Version().String()

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

touchedIDs[filename] = struct{}{}
}
}

// list statuses for cleanup
list, err = r.List(ctx, resource.NewMetadata(files.NamespaceName, files.EtcFileStatusType, "", resource.VersionUndefined))
if err != nil {
return fmt.Errorf("error listing resources: %w", err)
}

for _, res := range list.Items {
if _, ok := touchedIDs[res.Metadata().ID()]; !ok {
if err = r.Destroy(ctx, res.Metadata()); err != nil {
return fmt.Errorf("error cleaning up specs: %w", err)
}
}
}
}
}

// createBindMount creates a common way to create a writable source file with a
// bind mounted destination. This is most commonly used for well known files
// under /etc that need to be adjusted during startup.
func createBindMount(src, dst string) (err error) {
var f *os.File

if f, err = os.OpenFile(src, os.O_WRONLY|os.O_CREATE, 0o644); err != nil {
return err
}

if err = f.Close(); err != nil {
return err
}

if err = unix.Mount(src, dst, "", unix.MS_BIND, ""); err != nil {
return fmt.Errorf("failed to create bind mount for %s: %w", dst, err)
}

return nil
}
142 changes: 142 additions & 0 deletions internal/app/machined/pkg/controllers/files/etcfile_test.go
@@ -0,0 +1,142 @@
// 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_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/stretchr/testify/suite"
"github.com/talos-systems/go-retry/retry"

filesctrl "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/files"
"github.com/talos-systems/talos/pkg/logging"
"github.com/talos-systems/talos/pkg/resources/files"
)

type EtcFileSuite struct {
suite.Suite

state state.State

runtime *runtime.Runtime
wg sync.WaitGroup

ctx context.Context
ctxCancel context.CancelFunc

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()

suite.etcPath = suite.T().TempDir()
suite.shadowPath = suite.T().TempDir()

suite.Require().NoError(suite.runtime.RegisterController(&filesctrl.EtcFileController{
EtcPath: suite.etcPath,
ShadowPath: suite.shadowPath,
}))
}

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

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

suite.Assert().NoError(suite.runtime.Run(suite.ctx))
}()
}

func (suite *EtcFileSuite) assertEtcFile(filename, contents string, expectedVersion resource.Version) error {
b, err := os.ReadFile(filepath.Join(suite.etcPath, filename))
if err != nil {
return retry.ExpectedError(err)
}

if string(b) != contents {
return retry.ExpectedErrorf("contents don't match %q != %q", string(b), contents)
}

r, err := suite.state.Get(suite.ctx, resource.NewMetadata(files.NamespaceName, files.EtcFileStatusType, filename, resource.VersionUndefined))
if err != nil {
if state.IsNotFoundError(err) {
return retry.ExpectedError(err)
}

return err
}

version := r.(*files.EtcFileStatus).TypedSpec().SpecVersion

expected, err := strconv.Atoi(expectedVersion.String())
suite.Require().NoError(err)

ver, err := strconv.Atoi(version)
suite.Require().NoError(err)

if ver < expected {
return retry.ExpectedErrorf("version mismatch %s > %s", expectedVersion, version)
}

return nil
}

func (suite *EtcFileSuite) TestFiles() {
etcFileSpec := files.NewEtcFileSpec(files.NamespaceName, "test1")
etcFileSpec.TypedSpec().Contents = []byte("foo")
etcFileSpec.TypedSpec().Mode = 0o644

// create "read-only" mock (in Talos it's part of rootfs)
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.Assert().NoError(retry.Constant(5*time.Second, retry.WithUnits(100*time.Millisecond)).Retry(
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())
suite.Require().NoError(err)

if ready {
break
}

time.Sleep(100 * time.Millisecond)
}
}
}

func TestEtcFileSuite(t *testing.T) {
suite.Run(t, new(EtcFileSuite))
}
6 changes: 6 additions & 0 deletions internal/app/machined/pkg/controllers/files/files.go
@@ -0,0 +1,6 @@
// 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 provides controllers which manage file resources.
package files

0 comments on commit e74f789

Please sign in to comment.