mirror of
https://github.com/siderolabs/talos.git
synced 2025-11-01 17:01:10 +01:00
feat: implement EtcFileController to render files in /etc
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>
This commit is contained in:
parent
5aede1a833
commit
e74f789b01
186
internal/app/machined/pkg/controllers/files/etcfile.go
Normal file
186
internal/app/machined/pkg/controllers/files/etcfile.go
Normal file
@ -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
internal/app/machined/pkg/controllers/files/etcfile_test.go
Normal file
142
internal/app/machined/pkg/controllers/files/etcfile_test.go
Normal file
@ -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
internal/app/machined/pkg/controllers/files/files.go
Normal file
6
internal/app/machined/pkg/controllers/files/files.go
Normal file
@ -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
|
||||
214
internal/app/machined/pkg/controllers/network/etcfile.go
Normal file
214
internal/app/machined/pkg/controllers/network/etcfile.go
Normal file
@ -0,0 +1,214 @@
|
||||
// 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 network
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/AlekSi/pointer"
|
||||
"github.com/cosi-project/runtime/pkg/controller"
|
||||
"github.com/cosi-project/runtime/pkg/resource"
|
||||
"github.com/cosi-project/runtime/pkg/state"
|
||||
"go.uber.org/zap"
|
||||
|
||||
talosconfig "github.com/talos-systems/talos/pkg/machinery/config"
|
||||
"github.com/talos-systems/talos/pkg/resources/config"
|
||||
"github.com/talos-systems/talos/pkg/resources/files"
|
||||
"github.com/talos-systems/talos/pkg/resources/network"
|
||||
)
|
||||
|
||||
// EtcFileController creates /etc/hostname and /etc/resolv.conf files based on finalized network configuration.
|
||||
type EtcFileController struct{}
|
||||
|
||||
// Name implements controller.Controller interface.
|
||||
func (ctrl *EtcFileController) Name() string {
|
||||
return "network.EtcFileController"
|
||||
}
|
||||
|
||||
// Inputs implements controller.Controller interface.
|
||||
func (ctrl *EtcFileController) Inputs() []controller.Input {
|
||||
return []controller.Input{
|
||||
{
|
||||
Namespace: config.NamespaceName,
|
||||
Type: config.MachineConfigType,
|
||||
ID: pointer.ToString(config.V1Alpha1ID),
|
||||
Kind: controller.InputWeak,
|
||||
},
|
||||
{
|
||||
Namespace: network.NamespaceName,
|
||||
Type: network.HostnameStatusType,
|
||||
ID: pointer.ToString(network.HostnameID),
|
||||
Kind: controller.InputWeak,
|
||||
},
|
||||
{
|
||||
Namespace: network.NamespaceName,
|
||||
Type: network.ResolverStatusType,
|
||||
ID: pointer.ToString(network.ResolverID),
|
||||
Kind: controller.InputWeak,
|
||||
},
|
||||
{
|
||||
Namespace: network.NamespaceName,
|
||||
Type: network.NodeAddressType,
|
||||
ID: pointer.ToString(network.NodeAddressDefaultID),
|
||||
Kind: controller.InputWeak,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs implements controller.Controller interface.
|
||||
func (ctrl *EtcFileController) Outputs() []controller.Output {
|
||||
return []controller.Output{
|
||||
{
|
||||
Type: files.EtcFileSpecType,
|
||||
Kind: controller.OutputShared,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run implements controller.Controller interface.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func (ctrl *EtcFileController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-r.EventCh():
|
||||
}
|
||||
|
||||
var cfgProvider talosconfig.Provider
|
||||
|
||||
cfg, err := r.Get(ctx, resource.NewMetadata(config.NamespaceName, config.MachineConfigType, config.V1Alpha1ID, resource.VersionUndefined))
|
||||
if err != nil {
|
||||
if !state.IsNotFoundError(err) {
|
||||
return fmt.Errorf("error getting config: %w", err)
|
||||
}
|
||||
} else {
|
||||
cfgProvider = cfg.(*config.MachineConfig).Config()
|
||||
}
|
||||
|
||||
var resolverStatus *network.ResolverStatusSpec
|
||||
|
||||
rStatus, err := r.Get(ctx, resource.NewMetadata(network.NamespaceName, network.ResolverStatusType, network.ResolverID, resource.VersionUndefined))
|
||||
if err != nil {
|
||||
if !state.IsNotFoundError(err) {
|
||||
return fmt.Errorf("error getting resolver status: %w", err)
|
||||
}
|
||||
} else {
|
||||
resolverStatus = rStatus.(*network.ResolverStatus).TypedSpec()
|
||||
}
|
||||
|
||||
var hostnameStatus *network.HostnameStatusSpec
|
||||
|
||||
hStatus, err := r.Get(ctx, resource.NewMetadata(network.NamespaceName, network.HostnameStatusType, network.HostnameID, resource.VersionUndefined))
|
||||
if err != nil {
|
||||
if !state.IsNotFoundError(err) {
|
||||
return fmt.Errorf("error getting hostname status: %w", err)
|
||||
}
|
||||
} else {
|
||||
hostnameStatus = hStatus.(*network.HostnameStatus).TypedSpec()
|
||||
}
|
||||
|
||||
var nodeAddressStatus *network.NodeAddressSpec
|
||||
|
||||
naStatus, err := r.Get(ctx, resource.NewMetadata(network.NamespaceName, network.NodeAddressType, network.NodeAddressDefaultID, resource.VersionUndefined))
|
||||
if err != nil {
|
||||
if !state.IsNotFoundError(err) {
|
||||
return fmt.Errorf("error getting network address status: %w", err)
|
||||
}
|
||||
} else {
|
||||
nodeAddressStatus = naStatus.(*network.NodeAddress).TypedSpec()
|
||||
}
|
||||
|
||||
if resolverStatus != nil {
|
||||
if err = r.Modify(ctx, files.NewEtcFileSpec(files.NamespaceName, "resolv.conf"),
|
||||
func(r resource.Resource) error {
|
||||
r.(*files.EtcFileSpec).TypedSpec().Contents = ctrl.renderResolvConf(resolverStatus, hostnameStatus)
|
||||
r.(*files.EtcFileSpec).TypedSpec().Mode = 0o644
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error modifying resolv.conf: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if hostnameStatus != nil && nodeAddressStatus != nil {
|
||||
if err = r.Modify(ctx, files.NewEtcFileSpec(files.NamespaceName, "hosts"),
|
||||
func(r resource.Resource) error {
|
||||
r.(*files.EtcFileSpec).TypedSpec().Contents, err = ctrl.renderHosts(hostnameStatus, nodeAddressStatus, cfgProvider)
|
||||
r.(*files.EtcFileSpec).TypedSpec().Mode = 0o644
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error modifying resolv.conf: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl *EtcFileController) renderResolvConf(resolverStatus *network.ResolverStatusSpec, hostnameStatus *network.HostnameStatusSpec) []byte {
|
||||
var buf bytes.Buffer
|
||||
|
||||
for i, resolver := range resolverStatus.DNSServers {
|
||||
if i >= 3 {
|
||||
// only use firt 3 nameservers, see MAXNS in https://linux.die.net/man/5/resolv.conf
|
||||
break
|
||||
}
|
||||
|
||||
fmt.Fprintf(&buf, "nameserver %s\n", resolver)
|
||||
}
|
||||
|
||||
if hostnameStatus != nil && hostnameStatus.Domainname != "" {
|
||||
fmt.Fprintf(&buf, "\nsearch %s\n", hostnameStatus.Domainname)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
var hostsTemplate = template.Must(template.New("hosts").Parse(strings.TrimSpace(`
|
||||
127.0.0.1 localhost
|
||||
{{ .IP }} {{ .Hostname }} {{ if ne .Hostname .Alias }}{{ .Alias }}{{ end }}
|
||||
::1 localhost ip6-localhost ip6-loopback
|
||||
ff02::1 ip6-allnodes
|
||||
ff02::2 ip6-allrouters
|
||||
|
||||
{{- with .ExtraHosts }}
|
||||
{{ range . }}
|
||||
{{ .IP }} {{ range .Aliases }}{{.}} {{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
`)))
|
||||
|
||||
func (ctrl *EtcFileController) renderHosts(hostnameStatus *network.HostnameStatusSpec, nodeAddressStatus *network.NodeAddressSpec, cfgProvider talosconfig.Provider) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
extraHosts := []talosconfig.ExtraHost{}
|
||||
|
||||
if cfgProvider != nil {
|
||||
extraHosts = cfgProvider.Machine().Network().ExtraHosts()
|
||||
}
|
||||
|
||||
data := struct {
|
||||
IP string
|
||||
Hostname string
|
||||
Alias string
|
||||
ExtraHosts []talosconfig.ExtraHost
|
||||
}{
|
||||
IP: nodeAddressStatus.Addresses[0].String(),
|
||||
Hostname: hostnameStatus.FQDN(),
|
||||
Alias: hostnameStatus.Hostname,
|
||||
ExtraHosts: extraHosts,
|
||||
}
|
||||
|
||||
if err := hostsTemplate.Execute(&buf, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
245
internal/app/machined/pkg/controllers/network/etcfile_test.go
Normal file
245
internal/app/machined/pkg/controllers/network/etcfile_test.go
Normal file
@ -0,0 +1,245 @@
|
||||
// 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/.
|
||||
|
||||
//nolint:dupl
|
||||
package network_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"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"
|
||||
"inet.af/netaddr"
|
||||
|
||||
netctrl "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/network"
|
||||
"github.com/talos-systems/talos/pkg/logging"
|
||||
"github.com/talos-systems/talos/pkg/machinery/config/types/v1alpha1"
|
||||
"github.com/talos-systems/talos/pkg/resources/config"
|
||||
"github.com/talos-systems/talos/pkg/resources/files"
|
||||
"github.com/talos-systems/talos/pkg/resources/network"
|
||||
)
|
||||
|
||||
type EtcFileConfigSuite struct {
|
||||
suite.Suite
|
||||
|
||||
state state.State
|
||||
|
||||
runtime *runtime.Runtime
|
||||
wg sync.WaitGroup
|
||||
|
||||
ctx context.Context
|
||||
ctxCancel context.CancelFunc
|
||||
|
||||
cfg *config.MachineConfig
|
||||
defaultAddress *network.NodeAddress
|
||||
hostnameStatus *network.HostnameStatus
|
||||
resolverStatus *network.ResolverStatus
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) 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.Require().NoError(suite.runtime.RegisterController(&netctrl.EtcFileController{}))
|
||||
|
||||
u, err := url.Parse("https://foo:6443")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
suite.cfg = config.NewMachineConfig(&v1alpha1.Config{
|
||||
ConfigVersion: "v1alpha1",
|
||||
MachineConfig: &v1alpha1.MachineConfig{
|
||||
MachineNetwork: &v1alpha1.NetworkConfig{
|
||||
ExtraHostEntries: []*v1alpha1.ExtraHost{
|
||||
{
|
||||
HostIP: "10.0.0.1",
|
||||
HostAliases: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
HostIP: "10.0.0.2",
|
||||
HostAliases: []string{"c", "d"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ClusterConfig: &v1alpha1.ClusterConfig{
|
||||
ControlPlane: &v1alpha1.ControlPlaneConfig{
|
||||
Endpoint: &v1alpha1.Endpoint{
|
||||
URL: u,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
suite.defaultAddress = network.NewNodeAddress(network.NamespaceName, network.NodeAddressDefaultID)
|
||||
suite.defaultAddress.TypedSpec().Addresses = []netaddr.IP{netaddr.MustParseIP("33.11.22.44")}
|
||||
|
||||
suite.hostnameStatus = network.NewHostnameStatus(network.NamespaceName, network.HostnameID)
|
||||
suite.hostnameStatus.TypedSpec().Hostname = "foo"
|
||||
suite.hostnameStatus.TypedSpec().Domainname = "example.com"
|
||||
|
||||
suite.resolverStatus = network.NewResolverStatus(network.NamespaceName, network.ResolverID)
|
||||
suite.resolverStatus.TypedSpec().DNSServers = []netaddr.IP{netaddr.MustParseIP("1.1.1.1"), netaddr.MustParseIP("2.2.2.2"), netaddr.MustParseIP("3.3.3.3"), netaddr.MustParseIP("4.4.4.4")}
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) startRuntime() {
|
||||
suite.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer suite.wg.Done()
|
||||
|
||||
suite.Assert().NoError(suite.runtime.Run(suite.ctx))
|
||||
}()
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) assertEtcFiles(requiredIDs []string, check func(*files.EtcFileSpec) error) error {
|
||||
missingIDs := make(map[string]struct{}, len(requiredIDs))
|
||||
|
||||
for _, id := range requiredIDs {
|
||||
missingIDs[id] = struct{}{}
|
||||
}
|
||||
|
||||
resources, err := suite.state.List(suite.ctx, resource.NewMetadata(files.NamespaceName, files.EtcFileSpecType, "", resource.VersionUndefined))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, res := range resources.Items {
|
||||
_, required := missingIDs[res.Metadata().ID()]
|
||||
if !required {
|
||||
continue
|
||||
}
|
||||
|
||||
delete(missingIDs, res.Metadata().ID())
|
||||
|
||||
if err = check(res.(*files.EtcFileSpec)); err != nil {
|
||||
return retry.ExpectedError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingIDs) > 0 {
|
||||
return retry.ExpectedError(fmt.Errorf("some resources are missing: %q", missingIDs))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) assertNoEtcFile(id string) error {
|
||||
resources, err := suite.state.List(suite.ctx, resource.NewMetadata(files.NamespaceName, files.EtcFileSpecType, "", resource.VersionUndefined))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, res := range resources.Items {
|
||||
if res.Metadata().ID() == id {
|
||||
return retry.ExpectedError(fmt.Errorf("spec %q is still there", id))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) testFiles(resources []resource.Resource, resolvConf, hosts string) {
|
||||
for _, r := range resources {
|
||||
suite.Require().NoError(suite.state.Create(suite.ctx, r))
|
||||
}
|
||||
|
||||
expectedIds, unexpectedIds := []string{}, []string{}
|
||||
|
||||
if resolvConf != "" {
|
||||
expectedIds = append(expectedIds, "resolv.conf")
|
||||
} else {
|
||||
unexpectedIds = append(unexpectedIds, "resolv.conf")
|
||||
}
|
||||
|
||||
if hosts != "" {
|
||||
expectedIds = append(expectedIds, "hosts")
|
||||
} else {
|
||||
unexpectedIds = append(unexpectedIds, "hosts")
|
||||
}
|
||||
|
||||
suite.Assert().NoError(retry.Constant(3*time.Second, retry.WithUnits(100*time.Millisecond)).Retry(
|
||||
func() error {
|
||||
return suite.assertEtcFiles(
|
||||
expectedIds,
|
||||
func(r *files.EtcFileSpec) error {
|
||||
switch r.Metadata().ID() {
|
||||
case "hosts":
|
||||
suite.Assert().Equal(hosts, string(r.TypedSpec().Contents))
|
||||
case "resolv.conf":
|
||||
suite.Assert().Equal(resolvConf, string(r.TypedSpec().Contents))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}))
|
||||
|
||||
for _, id := range unexpectedIds {
|
||||
id := id
|
||||
|
||||
suite.Assert().NoError(retry.Constant(1*time.Second, retry.WithUnits(100*time.Millisecond)).Retry(
|
||||
func() error {
|
||||
return suite.assertNoEtcFile(id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) TestComplete() {
|
||||
suite.testFiles([]resource.Resource{suite.cfg, suite.defaultAddress, suite.hostnameStatus, suite.resolverStatus},
|
||||
"nameserver 1.1.1.1\nnameserver 2.2.2.2\nnameserver 3.3.3.3\n\nsearch example.com\n",
|
||||
"127.0.0.1 localhost\n33.11.22.44 foo.example.com foo\n::1 localhost ip6-localhost ip6-loopback\nff02::1 ip6-allnodes\nff02::2 ip6-allrouters\n\n10.0.0.1 a b \n10.0.0.2 c d ", //nolint:lll
|
||||
)
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) TestNoExtraHosts() {
|
||||
suite.testFiles([]resource.Resource{suite.defaultAddress, suite.hostnameStatus, suite.resolverStatus},
|
||||
"nameserver 1.1.1.1\nnameserver 2.2.2.2\nnameserver 3.3.3.3\n\nsearch example.com\n",
|
||||
"127.0.0.1 localhost\n33.11.22.44 foo.example.com foo\n::1 localhost ip6-localhost ip6-loopback\nff02::1 ip6-allnodes\nff02::2 ip6-allrouters",
|
||||
)
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) TestNoDomainname() {
|
||||
suite.hostnameStatus.TypedSpec().Domainname = ""
|
||||
|
||||
suite.testFiles([]resource.Resource{suite.defaultAddress, suite.hostnameStatus, suite.resolverStatus},
|
||||
"nameserver 1.1.1.1\nnameserver 2.2.2.2\nnameserver 3.3.3.3\n",
|
||||
"127.0.0.1 localhost\n33.11.22.44 foo \n::1 localhost ip6-localhost ip6-loopback\nff02::1 ip6-allnodes\nff02::2 ip6-allrouters",
|
||||
)
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) TestOnlyResolvers() {
|
||||
suite.testFiles([]resource.Resource{suite.resolverStatus},
|
||||
"nameserver 1.1.1.1\nnameserver 2.2.2.2\nnameserver 3.3.3.3\n",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func (suite *EtcFileConfigSuite) TestOnlyHostname() {
|
||||
suite.testFiles([]resource.Resource{suite.defaultAddress, suite.hostnameStatus},
|
||||
"",
|
||||
"127.0.0.1 localhost\n33.11.22.44 foo.example.com foo\n::1 localhost ip6-localhost ip6-loopback\nff02::1 ip6-allnodes\nff02::2 ip6-allrouters",
|
||||
)
|
||||
}
|
||||
|
||||
func TestEtcFileConfigSuite(t *testing.T) {
|
||||
suite.Run(t, new(EtcFileConfigSuite))
|
||||
}
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/talos-systems/talos/internal/app/machined/pkg/controllers/config"
|
||||
"github.com/talos-systems/talos/internal/app/machined/pkg/controllers/files"
|
||||
"github.com/talos-systems/talos/internal/app/machined/pkg/controllers/k8s"
|
||||
"github.com/talos-systems/talos/internal/app/machined/pkg/controllers/network"
|
||||
"github.com/talos-systems/talos/internal/app/machined/pkg/controllers/perf"
|
||||
@ -21,6 +22,7 @@ import (
|
||||
"github.com/talos-systems/talos/internal/app/machined/pkg/controllers/v1alpha1"
|
||||
"github.com/talos-systems/talos/internal/app/machined/pkg/runtime"
|
||||
"github.com/talos-systems/talos/pkg/logging"
|
||||
"github.com/talos-systems/talos/pkg/machinery/constants"
|
||||
)
|
||||
|
||||
// Controller implements runtime.V1alpha2Controller.
|
||||
@ -64,6 +66,10 @@ func (ctrl *Controller) Run(ctx context.Context) error {
|
||||
},
|
||||
&config.MachineTypeController{},
|
||||
&config.K8sControlPlaneController{},
|
||||
&files.EtcFileController{
|
||||
EtcPath: "/etc",
|
||||
ShadowPath: constants.SystemEtcPath,
|
||||
},
|
||||
&k8s.ControlPlaneStaticPodController{},
|
||||
&k8s.ExtraManifestController{},
|
||||
&k8s.KubeletStaticPodController{},
|
||||
@ -76,6 +82,8 @@ func (ctrl *Controller) Run(ctx context.Context) error {
|
||||
&network.AddressMergeController{},
|
||||
&network.AddressSpecController{},
|
||||
&network.AddressStatusController{},
|
||||
// TODO: disabled to avoid conflict with networkd
|
||||
// &network.EtcFileController{},
|
||||
&network.HostnameConfigController{
|
||||
Cmdline: procfs.ProcCmdline(),
|
||||
},
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
|
||||
talosconfig "github.com/talos-systems/talos/pkg/machinery/config"
|
||||
"github.com/talos-systems/talos/pkg/resources/config"
|
||||
"github.com/talos-systems/talos/pkg/resources/files"
|
||||
"github.com/talos-systems/talos/pkg/resources/k8s"
|
||||
"github.com/talos-systems/talos/pkg/resources/network"
|
||||
"github.com/talos-systems/talos/pkg/resources/perf"
|
||||
@ -56,10 +57,11 @@ func NewState() (*State, error) {
|
||||
}{
|
||||
{v1alpha1.NamespaceName, "Talos v1alpha1 subsystems glue resources."},
|
||||
{config.NamespaceName, "Talos node configuration."},
|
||||
{files.NamespaceName, "Files and file-like resources."},
|
||||
{k8s.ControlPlaneNamespaceName, "Kubernetes control plane resources."},
|
||||
{secrets.NamespaceName, "Resources with secret material."},
|
||||
{network.NamespaceName, "Networking resources."},
|
||||
{network.ConfigNamespaceName, "Networking configuration resources."},
|
||||
{secrets.NamespaceName, "Resources with secret material."},
|
||||
{perf.NamespaceName, "Stats resources."},
|
||||
} {
|
||||
if err := s.namespaceRegistry.Register(ctx, ns.name, ns.description); err != nil {
|
||||
@ -74,6 +76,8 @@ func NewState() (*State, error) {
|
||||
&config.MachineConfig{},
|
||||
&config.MachineType{},
|
||||
&config.K8sControlPlane{},
|
||||
&files.EtcFileSpec{},
|
||||
&files.EtcFileStatus{},
|
||||
&k8s.Manifest{},
|
||||
&k8s.ManifestStatus{},
|
||||
&k8s.StaticPod{},
|
||||
|
||||
80
pkg/resources/files/etcfile_spec.go
Normal file
80
pkg/resources/files/etcfile_spec.go
Normal file
@ -0,0 +1,80 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"github.com/cosi-project/runtime/pkg/resource"
|
||||
"github.com/cosi-project/runtime/pkg/resource/meta"
|
||||
)
|
||||
|
||||
// EtcFileSpecType is type of EtcFile resource.
|
||||
const EtcFileSpecType = resource.Type("EtcFileSpecs.files.talos.dev")
|
||||
|
||||
// EtcFileSpec resource holds contents of the file which should be put to `/etc` directory.
|
||||
type EtcFileSpec struct {
|
||||
md resource.Metadata
|
||||
spec EtcFileSpecSpec
|
||||
}
|
||||
|
||||
// EtcFileSpecSpec describes status of rendered secrets.
|
||||
type EtcFileSpecSpec struct {
|
||||
Contents []byte `yaml:"contents"`
|
||||
Mode fs.FileMode `yaml:"mode"`
|
||||
}
|
||||
|
||||
// NewEtcFileSpec initializes a EtcFileSpec resource.
|
||||
func NewEtcFileSpec(namespace resource.Namespace, id resource.ID) *EtcFileSpec {
|
||||
r := &EtcFileSpec{
|
||||
md: resource.NewMetadata(namespace, EtcFileSpecType, id, resource.VersionUndefined),
|
||||
spec: EtcFileSpecSpec{},
|
||||
}
|
||||
|
||||
r.md.BumpVersion()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Metadata implements resource.Resource.
|
||||
func (r *EtcFileSpec) Metadata() *resource.Metadata {
|
||||
return &r.md
|
||||
}
|
||||
|
||||
// Spec implements resource.Resource.
|
||||
func (r *EtcFileSpec) Spec() interface{} {
|
||||
return r.spec
|
||||
}
|
||||
|
||||
func (r *EtcFileSpec) String() string {
|
||||
return fmt.Sprintf("network.EtcFileSpec(%q)", r.md.ID())
|
||||
}
|
||||
|
||||
// DeepCopy implements resource.Resource.
|
||||
func (r *EtcFileSpec) DeepCopy() resource.Resource {
|
||||
return &EtcFileSpec{
|
||||
md: r.md,
|
||||
spec: EtcFileSpecSpec{
|
||||
Contents: append([]byte(nil), r.spec.Contents...),
|
||||
Mode: r.spec.Mode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceDefinition implements meta.ResourceDefinitionProvider interface.
|
||||
func (r *EtcFileSpec) ResourceDefinition() meta.ResourceDefinitionSpec {
|
||||
return meta.ResourceDefinitionSpec{
|
||||
Type: EtcFileSpecType,
|
||||
Aliases: []resource.Type{},
|
||||
DefaultNamespace: NamespaceName,
|
||||
PrintColumns: []meta.PrintColumn{},
|
||||
}
|
||||
}
|
||||
|
||||
// TypedSpec allows to access the Spec with the proper type.
|
||||
func (r *EtcFileSpec) TypedSpec() *EtcFileSpecSpec {
|
||||
return &r.spec
|
||||
}
|
||||
75
pkg/resources/files/etcfile_status.go
Normal file
75
pkg/resources/files/etcfile_status.go
Normal file
@ -0,0 +1,75 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
|
||||
"github.com/cosi-project/runtime/pkg/resource"
|
||||
"github.com/cosi-project/runtime/pkg/resource/meta"
|
||||
)
|
||||
|
||||
// EtcFileStatusType is type of EtcFile resource.
|
||||
const EtcFileStatusType = resource.Type("EtcFileStatuses.files.talos.dev")
|
||||
|
||||
// EtcFileStatus resource holds contents of the file which should be put to `/etc` directory.
|
||||
type EtcFileStatus struct {
|
||||
md resource.Metadata
|
||||
spec EtcFileStatusSpec
|
||||
}
|
||||
|
||||
// EtcFileStatusSpec describes status of rendered secrets.
|
||||
type EtcFileStatusSpec struct {
|
||||
SpecVersion string `yaml:"specVersion"`
|
||||
}
|
||||
|
||||
// NewEtcFileStatus initializes a EtcFileStatus resource.
|
||||
func NewEtcFileStatus(namespace resource.Namespace, id resource.ID) *EtcFileStatus {
|
||||
r := &EtcFileStatus{
|
||||
md: resource.NewMetadata(namespace, EtcFileStatusType, id, resource.VersionUndefined),
|
||||
spec: EtcFileStatusSpec{},
|
||||
}
|
||||
|
||||
r.md.BumpVersion()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Metadata implements resource.Resource.
|
||||
func (r *EtcFileStatus) Metadata() *resource.Metadata {
|
||||
return &r.md
|
||||
}
|
||||
|
||||
// Spec implements resource.Resource.
|
||||
func (r *EtcFileStatus) Spec() interface{} {
|
||||
return r.spec
|
||||
}
|
||||
|
||||
func (r *EtcFileStatus) String() string {
|
||||
return fmt.Sprintf("network.EtcFileStatus(%q)", r.md.ID())
|
||||
}
|
||||
|
||||
// DeepCopy implements resource.Resource.
|
||||
func (r *EtcFileStatus) DeepCopy() resource.Resource {
|
||||
return &EtcFileStatus{
|
||||
md: r.md,
|
||||
spec: r.spec,
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceDefinition implements meta.ResourceDefinitionProvider interface.
|
||||
func (r *EtcFileStatus) ResourceDefinition() meta.ResourceDefinitionSpec {
|
||||
return meta.ResourceDefinitionSpec{
|
||||
Type: EtcFileStatusType,
|
||||
Aliases: []resource.Type{},
|
||||
DefaultNamespace: NamespaceName,
|
||||
PrintColumns: []meta.PrintColumn{},
|
||||
}
|
||||
}
|
||||
|
||||
// TypedSpec allows to access the Spec with the proper type.
|
||||
func (r *EtcFileStatus) TypedSpec() *EtcFileStatusSpec {
|
||||
return &r.spec
|
||||
}
|
||||
11
pkg/resources/files/files.go
Normal file
11
pkg/resources/files/files.go
Normal file
@ -0,0 +1,11 @@
|
||||
// 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 resources which describe files on disk.
|
||||
package files
|
||||
|
||||
import "github.com/cosi-project/runtime/pkg/resource"
|
||||
|
||||
// NamespaceName contains file resources.
|
||||
const NamespaceName resource.Namespace = "files"
|
||||
33
pkg/resources/files/files_test.go
Normal file
33
pkg/resources/files/files_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
// 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"
|
||||
"testing"
|
||||
|
||||
"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/cosi-project/runtime/pkg/state/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/talos-systems/talos/pkg/resources/files"
|
||||
)
|
||||
|
||||
func TestRegisterResource(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
resources := state.WrapCore(namespaced.NewState(inmem.Build))
|
||||
resourceRegistry := registry.NewResourceRegistry(resources)
|
||||
|
||||
for _, resource := range []resource.Resource{
|
||||
&files.EtcFileSpec{},
|
||||
&files.EtcFileStatus{},
|
||||
} {
|
||||
assert.NoError(t, resourceRegistry.Register(ctx, resource))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user