diff --git a/internal/app/machined/pkg/controllers/files/etcfile.go b/internal/app/machined/pkg/controllers/files/etcfile.go new file mode 100644 index 000000000..366124efd --- /dev/null +++ b/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 +} diff --git a/internal/app/machined/pkg/controllers/files/etcfile_test.go b/internal/app/machined/pkg/controllers/files/etcfile_test.go new file mode 100644 index 000000000..682ad9e0c --- /dev/null +++ b/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)) +} diff --git a/internal/app/machined/pkg/controllers/files/files.go b/internal/app/machined/pkg/controllers/files/files.go new file mode 100644 index 000000000..998970e36 --- /dev/null +++ b/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 diff --git a/internal/app/machined/pkg/controllers/network/etcfile.go b/internal/app/machined/pkg/controllers/network/etcfile.go new file mode 100644 index 000000000..723124dc7 --- /dev/null +++ b/internal/app/machined/pkg/controllers/network/etcfile.go @@ -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 +} diff --git a/internal/app/machined/pkg/controllers/network/etcfile_test.go b/internal/app/machined/pkg/controllers/network/etcfile_test.go new file mode 100644 index 000000000..2d68e0df3 --- /dev/null +++ b/internal/app/machined/pkg/controllers/network/etcfile_test.go @@ -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)) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go index d2ddb8217..89f8dcda8 100644 --- a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go +++ b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go @@ -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(), }, diff --git a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go index 4fe98625b..b84f2e46b 100644 --- a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go +++ b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go @@ -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{}, diff --git a/pkg/resources/files/etcfile_spec.go b/pkg/resources/files/etcfile_spec.go new file mode 100644 index 000000000..fe3805010 --- /dev/null +++ b/pkg/resources/files/etcfile_spec.go @@ -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 +} diff --git a/pkg/resources/files/etcfile_status.go b/pkg/resources/files/etcfile_status.go new file mode 100644 index 000000000..99cbe44c1 --- /dev/null +++ b/pkg/resources/files/etcfile_status.go @@ -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 +} diff --git a/pkg/resources/files/files.go b/pkg/resources/files/files.go new file mode 100644 index 000000000..a3c07157c --- /dev/null +++ b/pkg/resources/files/files.go @@ -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" diff --git a/pkg/resources/files/files_test.go b/pkg/resources/files/files_test.go new file mode 100644 index 000000000..ca2acb049 --- /dev/null +++ b/pkg/resources/files/files_test.go @@ -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)) + } +}