diff --git a/cmd/agent_setup.go b/cmd/agent_setup.go index ae7653c0e..cc7d86132 100644 --- a/cmd/agent_setup.go +++ b/cmd/agent_setup.go @@ -84,6 +84,10 @@ func setupAgent( execManager := exec.New(log, appConfig.Agent.PrivilegeEscalation.Enabled) + // --- File provider (created early — DNS, sysctl, cron, etc. depend on it) --- + hostname, _ := job.GetAgentHostname(appConfig.Agent.Hostname) + fileProvider, fileStateKV := createFileProvider(ctx, log, b, namespace, hostname) + // --- Node providers --- var hostProvider nodeHost.Provider switch plat { @@ -136,7 +140,7 @@ func setupAgent( if platform.IsContainer() { dnsProvider = dns.NewDebianDockerProvider(log, appFs) } else { - dnsProvider = dns.NewDebianProvider(log, execManager) + dnsProvider = dns.NewDebianProvider(log, appFs, fileStateKV, execManager, hostname) } case "darwin": dnsProvider = dns.NewDarwinProvider(log, execManager) @@ -180,10 +184,6 @@ func setupAgent( slog.String("error", err.Error())) } - // --- File provider --- - hostname, _ := job.GetAgentHostname(appConfig.Agent.Hostname) - fileProvider, fileStateKV := createFileProvider(ctx, log, b, namespace, hostname) - // --- Cron provider --- cronProvider := createCronProvider(log, fileProvider, fileStateKV, hostname) diff --git a/docs/docs/sidebar/features/network-management.md b/docs/docs/sidebar/features/network-management.md index 659706c78..2726f83d9 100644 --- a/docs/docs/sidebar/features/network-management.md +++ b/docs/docs/sidebar/features/network-management.md @@ -18,8 +18,10 @@ unprivileged while agents execute the actual changes. ## How It Works **DNS** -- queries read the current nameserver configuration for a network -interface. Updates modify the nameservers and search domains, applying changes -through the host's network manager. The `--interface-name` parameter supports +interface via `resolvectl`. Updates generate a persistent Netplan configuration +file (`/etc/netplan/osapi-dns.yaml`) targeting the primary interface, validate +with `netplan generate`, and apply with `netplan apply`. This ensures DNS +changes survive reboots. The `--interface-name` parameter supports [fact references](system-facts.md) — use `@fact.interface.primary` to automatically target the default route interface. diff --git a/docs/docs/sidebar/features/system-facts.md b/docs/docs/sidebar/features/system-facts.md index 31bad7091..daf2ab5e1 100644 --- a/docs/docs/sidebar/features/system-facts.md +++ b/docs/docs/sidebar/features/system-facts.md @@ -120,11 +120,29 @@ which reference could not be resolved. ### Supported Contexts -Fact references work in any string value within job request data: +Fact references work in **any string value** in any API request that reaches an +agent. The agent resolves all `@fact.*` tokens before passing data to the +provider — no special handling is needed per endpoint. If a field accepts a +string, it accepts a fact reference. -- **Command arguments** — `--args "@fact.hostname"` -- **DNS interface name** — `--interface-name @fact.interface.primary` -- **Nested values** — references inside maps and arrays are resolved recursively +This includes: + +- **Standalone strings** — `--interface-name @fact.interface.primary` +- **Arrays** — `--servers "1.1.1.1,@fact.custom.dns_server"` resolves the fact + while keeping the literal IP +- **Nested maps** — references inside JSON objects are resolved recursively +- **Any domain** — commands, DNS, sysctl, services, packages, etc. + +```bash +# Fact reference in an array field +osapi client node network dns update \ + --servers 1.1.1.1 --servers @fact.custom.backup_dns \ + --interface-name @fact.interface.primary + +# Each agent resolves its own facts, so broadcast works: +# web-01 might resolve @fact.interface.primary to "eth0" +# web-02 might resolve it to "ens3" +``` Non-string values (numbers, booleans) are not modified. diff --git a/internal/exec/mocks/dns.go b/internal/exec/mocks/dns.go index 7d29f72a5..e02c9f187 100644 --- a/internal/exec/mocks/dns.go +++ b/internal/exec/mocks/dns.go @@ -23,7 +23,6 @@ package mocks import ( "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" ) const ( @@ -32,6 +31,9 @@ const ( // ResolveCommand represents the `resolvectl` command used for resolving network settings. ResolveCommand = "resolvectl" + + // NetplanCommand represents the `netplan` command used for applying network configuration. + NetplanCommand = "netplan" ) // NewPlainMockManager creates a Mock without defaults. @@ -81,138 +83,85 @@ DNS Servers: 192.168.1.1 8.8.8.8 8.8.4.4 2001:4860:4860::8888 2001:4860:4860::88 return mock } -// NewSetResolvConfMockManager creates a DNS Mock for SetResolvConf. +// NewSetResolvConfMockManager creates a DNS Mock for UpdateResolvConfByInterface +// with new servers and domains that differ from the existing config. func NewSetResolvConfMockManager(ctrl *gomock.Controller) *MockManager { output := ` Current Scopes: DNS Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported -Current DNS Server: 8.8.8.8 -DNS Servers: 8.8.8.8 9.9.9.9 -DNS Domain: foo.local bar.local +Current DNS Server: 1.1.1.1 +DNS Servers: 1.1.1.1 2.2.2.2 +DNS Domain: old.local ` mock := NewMockManager(ctrl) mockRunCmdStatus(mock, output) - mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil) - mockRunCmdDomain(mock, []string{"foo.local", "bar.local"}, nil) + mockNetplanApply(mock, nil, nil) return mock } -// NewSetResolvConfPreserveDNSServersMockManager creates a DNS Mock for SetResolvConf -// with existing DNS Servers. +// NewSetResolvConfPreserveDNSServersMockManager creates a DNS Mock for +// UpdateResolvConfByInterface with existing DNS Servers preserved. func NewSetResolvConfPreserveDNSServersMockManager(ctrl *gomock.Controller) *MockManager { - initialOutput := ` + output := ` Current Scopes: DNS Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported Current DNS Server: 192.168.1.1 DNS Servers: 1.1.1.1 2.2.2.2 DNS Domain: example.com local.lan -` - - subsequentOutput := ` -Current Scopes: DNS -Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported -Current DNS Server: 1.1.1.1 -DNS Servers: 1.1.1.1 2.2.2.2 -DNS Domain: foo.local bar.local ` mock := NewMockManager(ctrl) - gomock.InOrder( - mock.EXPECT(). - RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}). - Return(initialOutput, nil), - - mock.EXPECT(). - RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}). - Return(subsequentOutput, nil), - ) - - mockRunCmdDNS(mock, []string{"1.1.1.1", "2.2.2.2"}, nil) - mockRunCmdDomain(mock, []string{"foo.local", "bar.local"}, nil) + mockRunCmdStatus(mock, output) + mockNetplanApply(mock, nil, nil) return mock } -// NewSetResolvConfPreserveDNSDomainMockManager creates a DNS Mock for SetResolvConf -// with existing DNS Domain. +// NewSetResolvConfPreserveDNSDomainMockManager creates a DNS Mock for +// UpdateResolvConfByInterface with existing DNS Domain preserved. func NewSetResolvConfPreserveDNSDomainMockManager(ctrl *gomock.Controller) *MockManager { - initialOutput := ` + output := ` Current Scopes: DNS Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported Current DNS Server: 192.168.1.1 DNS Servers: 1.1.1.1 2.2.2.2 DNS Domain: foo.example.com bar.example.com -` - - subsequentOutput := ` -Current Scopes: DNS -Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported -Current DNS Server: 1.1.1.1 -DNS Servers: 8.8.8.8 9.9.9.9 -DNS Domain: foo.example.com bar.example.com ` mock := NewMockManager(ctrl) - gomock.InOrder( - mock.EXPECT(). - RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}). - Return(initialOutput, nil), - - mock.EXPECT(). - RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}). - Return(subsequentOutput, nil), - ) - - mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil) - mockRunCmdDomain(mock, []string{"foo.example.com", "bar.example.com"}, nil) + mockRunCmdStatus(mock, output) + mockNetplanApply(mock, nil, nil) return mock } -// NewSetResolvConfFiltersRootDNSDomainMockManager creates a DNS Mock for SetResolvConf -// with no DNS Domain. +// NewSetResolvConfFiltersRootDNSDomainMockManager creates a DNS Mock for +// UpdateResolvConfByInterface with no DNS Domain (only root "."). func NewSetResolvConfFiltersRootDNSDomainMockManager(ctrl *gomock.Controller) *MockManager { - initialOutput := ` + output := ` Current Scopes: DNS Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported Current DNS Server: 192.168.1.1 DNS Servers: 1.1.1.1 2.2.2.2 -` - - subsequentOutput := ` -Current Scopes: DNS -Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported -Current DNS Server: 1.1.1.1 -DNS Servers: 8.8.8.8 9.9.9.9 ` mock := NewMockManager(ctrl) - gomock.InOrder( - mock.EXPECT(). - RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}). - Return(initialOutput, nil), - - mock.EXPECT(). - RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}). - Return(subsequentOutput, nil), - ) - - mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil) - mockRunCmdDomain(mock, []string{"foo.example.com", "bar.example.com"}, nil) + mockRunCmdStatus(mock, output) + mockNetplanApply(mock, nil, nil) return mock } -// NewSetResolvConfSetDNSDomainErrorMockManager creates a DNS Mock for SetResolvConf -// when exec.RunCmd errors setting DNS Domain. -func NewSetResolvConfSetDNSDomainErrorMockManager(ctrl *gomock.Controller) *MockManager { - // Initial state must differ from desired so the no-op check does not skip the update. +// NewSetResolvConfNetplanGenerateErrorMockManager creates a DNS Mock for +// UpdateResolvConfByInterface when `netplan generate` fails. +func NewSetResolvConfNetplanGenerateErrorMockManager(ctrl *gomock.Controller) *MockManager { + // Initial state must differ from desired so the update proceeds. output := ` Current Scopes: DNS Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported @@ -224,30 +173,22 @@ DNS Domain: old.local mock := NewMockManager(ctrl) mockRunCmdStatus(mock, output) - mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil) - mockRunCmdDomain(mock, []string{"foo.local", "bar.local"}, assert.AnError) return mock } -// NewSetResolvConfSetDNSServersErrorMockManager creates a DNS Mock for SetResolvConf -// when exec.RunCmd errors setting DNS Servers. -func NewSetResolvConfSetDNSServersErrorMockManager(ctrl *gomock.Controller) *MockManager { - // Initial state must differ from desired so the no-op check does not skip the update. - output := ` -Current Scopes: DNS -Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported -Current DNS Server: 1.1.1.1 -DNS Servers: 1.1.1.1 2.2.2.2 -DNS Domain: old.local -` - - mock := NewMockManager(ctrl) - - mockRunCmdStatus(mock, output) - mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, assert.AnError) +// NewSetResolvConfSetDNSDomainErrorMockManager creates a DNS Mock for +// UpdateResolvConfByInterface when the write path fails. Kept for +// backwards compatibility with existing test names. +func NewSetResolvConfSetDNSDomainErrorMockManager(ctrl *gomock.Controller) *MockManager { + return NewSetResolvConfNetplanGenerateErrorMockManager(ctrl) +} - return mock +// NewSetResolvConfSetDNSServersErrorMockManager creates a DNS Mock for +// UpdateResolvConfByInterface when the write path fails. Kept for +// backwards compatibility with existing test names. +func NewSetResolvConfSetDNSServersErrorMockManager(ctrl *gomock.Controller) *MockManager { + return NewSetResolvConfNetplanGenerateErrorMockManager(ctrl) } // mockRunCmdStatus sets up a mock for the "status" RunCmd call. @@ -258,18 +199,17 @@ func mockRunCmdStatus(mock *MockManager, output string) { AnyTimes() } -// mockRunCmdDNS sets up a mock for the "dns" RunPrivilegedCmd call. -func mockRunCmdDNS(mock *MockManager, dnsServers []string, err error) { +// mockNetplanApply sets up mocks for `netplan generate` and `netplan apply`. +func mockNetplanApply(mock *MockManager, genErr error, applyErr error) { mock.EXPECT(). - RunPrivilegedCmd(ResolveCommand, append([]string{"dns", NetworkInterfaceName}, dnsServers...)). - Return("", err). + RunPrivilegedCmd(NetplanCommand, []string{"generate"}). + Return("", genErr). AnyTimes() -} -// mockRunCmdDomain sets up a mock for the "domain" RunPrivilegedCmd call. -func mockRunCmdDomain(mock *MockManager, domains []string, err error) { - mock.EXPECT(). - RunPrivilegedCmd(ResolveCommand, append([]string{"domain", NetworkInterfaceName}, domains...)). - Return("", err). - AnyTimes() + if genErr == nil { + mock.EXPECT(). + RunPrivilegedCmd(NetplanCommand, []string{"apply"}). + Return("", applyErr). + AnyTimes() + } } diff --git a/internal/provider/network/dns/debian.go b/internal/provider/network/dns/debian.go index 27b1c2568..7710b60a2 100644 --- a/internal/provider/network/dns/debian.go +++ b/internal/provider/network/dns/debian.go @@ -23,6 +23,9 @@ package dns import ( "log/slog" + "github.com/avfs/avfs" + "github.com/nats-io/nats.go/jetstream" + "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/provider" ) @@ -35,16 +38,25 @@ type Debian struct { provider.FactsAware logger *slog.Logger + fs avfs.VFS + stateKV jetstream.KeyValue execManager exec.Manager + hostname string } // NewDebianProvider factory to create a new Debian instance. func NewDebianProvider( logger *slog.Logger, + fs avfs.VFS, + stateKV jetstream.KeyValue, em exec.Manager, + hostname string, ) *Debian { return &Debian{ logger: logger.With(slog.String("subsystem", "provider.dns")), + fs: fs, + stateKV: stateKV, execManager: em, + hostname: hostname, } } diff --git a/internal/provider/network/dns/debian_get_resolv_conf_by_interface_public_test.go b/internal/provider/network/dns/debian_get_resolv_conf_by_interface_public_test.go index d37ae0f4a..b19b59ae2 100644 --- a/internal/provider/network/dns/debian_get_resolv_conf_by_interface_public_test.go +++ b/internal/provider/network/dns/debian_get_resolv_conf_by_interface_public_test.go @@ -26,6 +26,7 @@ import ( "os" "testing" + "github.com/avfs/avfs/vfs/memfs" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -148,7 +149,7 @@ func (suite *DebianGetResolvConfPublicTestSuite) TestGetResolvConfByInterface() suite.Run(tc.name, func() { mock := tc.setupMock() - net := dns.NewDebianProvider(suite.logger, mock) + net := dns.NewDebianProvider(suite.logger, memfs.New(), nil, mock, "test-host") got, err := net.GetResolvConfByInterface(tc.interfaceName) if !tc.wantErr { diff --git a/internal/provider/network/dns/debian_netplan.go b/internal/provider/network/dns/debian_netplan.go new file mode 100644 index 000000000..38e99d5c0 --- /dev/null +++ b/internal/provider/network/dns/debian_netplan.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package dns + +import ( + "fmt" + "strings" +) + +const ( + netplanDir = "/etc/netplan" + dnsFilePrefix = "osapi-dns" +) + +func dnsNetplanPath() string { + return netplanDir + "/" + dnsFilePrefix + ".yaml" +} + +func generateDNSNetplanYAML( + interfaceName string, + servers []string, + searchDomains []string, +) []byte { + var b strings.Builder + + b.WriteString("network:\n") + b.WriteString(" version: 2\n") + b.WriteString(" ethernets:\n") + fmt.Fprintf(&b, " %s:\n", interfaceName) + b.WriteString(" nameservers:\n") + + if len(servers) > 0 { + b.WriteString(" addresses:\n") + for _, s := range servers { + fmt.Fprintf(&b, " - %s\n", s) + } + } + + if len(searchDomains) > 0 { + b.WriteString(" search:\n") + for _, d := range searchDomains { + fmt.Fprintf(&b, " - %s\n", d) + } + } + + return []byte(b.String()) +} + +// resolvePrimaryInterface returns the network interface to use for +// Netplan configuration. It prefers the explicitly provided interface +// name, falls back to the primary_interface from agent facts, and +// defaults to "eth0" as a last resort. +func (u *Debian) resolvePrimaryInterface( + interfaceName string, +) string { + if interfaceName != "" { + return interfaceName + } + + facts := u.Facts() + if facts != nil { + if iface, ok := facts["primary_interface"].(string); ok && iface != "" { + return iface + } + } + + return "eth0" +} diff --git a/internal/provider/network/dns/debian_netplan_public_test.go b/internal/provider/network/dns/debian_netplan_public_test.go new file mode 100644 index 000000000..e33b11669 --- /dev/null +++ b/internal/provider/network/dns/debian_netplan_public_test.go @@ -0,0 +1,214 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package dns_test + +import ( + "log/slog" + "os" + "testing" + + "github.com/avfs/avfs/vfs/memfs" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/exec/mocks" + "github.com/retr0h/osapi/internal/provider/network/dns" +) + +type DebianNetplanPublicTestSuite struct { + suite.Suite + + logger *slog.Logger +} + +func (suite *DebianNetplanPublicTestSuite) SetupTest() { + suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (suite *DebianNetplanPublicTestSuite) TestGenerateDNSNetplanYAML() { + tests := []struct { + name string + interfaceName string + servers []string + searchDomains []string + validateFunc func(got []byte) + }{ + { + name: "when servers and search domains are provided", + interfaceName: "eth0", + servers: []string{"8.8.8.8", "8.8.4.4"}, + searchDomains: []string{"example.com", "local.lan"}, + validateFunc: func(got []byte) { + content := string(got) + suite.Contains(content, "addresses:") + suite.Contains(content, "- 8.8.8.8") + suite.Contains(content, "- 8.8.4.4") + suite.Contains(content, "search:") + suite.Contains(content, "- example.com") + suite.Contains(content, "- local.lan") + }, + }, + { + name: "when only servers are provided", + interfaceName: "eth0", + servers: []string{"1.1.1.1"}, + searchDomains: nil, + validateFunc: func(got []byte) { + content := string(got) + suite.Contains(content, "addresses:") + suite.Contains(content, "- 1.1.1.1") + suite.NotContains(content, "search:") + }, + }, + { + name: "when only search domains are provided", + interfaceName: "eth0", + servers: nil, + searchDomains: []string{"example.com"}, + validateFunc: func(got []byte) { + content := string(got) + suite.NotContains(content, "addresses:") + suite.Contains(content, "search:") + suite.Contains(content, "- example.com") + }, + }, + { + name: "when interface name appears in output", + interfaceName: "wlp0s20f3", + servers: []string{"8.8.8.8"}, + searchDomains: nil, + validateFunc: func(got []byte) { + suite.Contains(string(got), "wlp0s20f3:") + }, + }, + { + name: "when multiple servers each appear on own line", + interfaceName: "eth0", + servers: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"}, + searchDomains: nil, + validateFunc: func(got []byte) { + content := string(got) + suite.Contains(content, "- 192.168.1.1\n") + suite.Contains(content, "- 192.168.1.2\n") + suite.Contains(content, "- 192.168.1.3\n") + }, + }, + { + name: "when IPv6 servers are provided", + interfaceName: "eth0", + servers: []string{"2001:4860:4860::8888", "2001:4860:4860::8844"}, + searchDomains: nil, + validateFunc: func(got []byte) { + content := string(got) + suite.Contains(content, "- 2001:4860:4860::8888") + suite.Contains(content, "- 2001:4860:4860::8844") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got := dns.ExportGenerateDNSNetplanYAML( + tc.interfaceName, + tc.servers, + tc.searchDomains, + ) + + tc.validateFunc(got) + }) + } +} + +func (suite *DebianNetplanPublicTestSuite) TestDNSNetplanPath() { + tests := []struct { + name string + validateFunc func(got string) + }{ + { + name: "when path is returned", + validateFunc: func(got string) { + suite.Equal("/etc/netplan/osapi-dns.yaml", got) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got := dns.ExportDNSNetplanPath() + + tc.validateFunc(got) + }) + } +} + +func (suite *DebianNetplanPublicTestSuite) TestResolvePrimaryInterface() { + tests := []struct { + name string + interfaceName string + setupFacts func(p *dns.Debian) + want string + }{ + { + name: "when explicit interface name is provided", + interfaceName: "enp3s0", + setupFacts: func(_ *dns.Debian) {}, + want: "enp3s0", + }, + { + name: "when empty interface and facts has primary_interface", + interfaceName: "", + setupFacts: func(p *dns.Debian) { + p.SetFactsFunc(func() map[string]any { + return map[string]any{ + "primary_interface": "ens3", + } + }) + }, + want: "ens3", + }, + { + name: "when empty interface and no facts", + interfaceName: "", + setupFacts: func(_ *dns.Debian) {}, + want: "eth0", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + ctrl := gomock.NewController(suite.T()) + mock := mocks.NewPlainMockManager(ctrl) + + p := dns.NewDebianProvider(suite.logger, memfs.New(), nil, mock, "test-host") + tc.setupFacts(p) + + got := p.ExportResolvePrimaryInterface(tc.interfaceName) + + suite.Equal(tc.want, got) + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestDebianNetplanPublicTestSuite(t *testing.T) { + suite.Run(t, new(DebianNetplanPublicTestSuite)) +} diff --git a/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go b/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go index 79d6feeb8..b2267eff4 100644 --- a/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go +++ b/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go @@ -21,44 +21,29 @@ package dns import ( + "context" "fmt" "log/slog" - "slices" "strings" + + "github.com/retr0h/osapi/internal/provider/network/netplan" ) -// UpdateResolvConfByInterface updates the DNS configuration for a specific network interface -// using the `resolvectl` command. It applies new DNS servers and search domains -// if provided, while preserving existing settings for values that are not specified. -// The function returns an error if the operation fails. -// -// Cross-platform considerations: -// - This function is designed specifically for Linux systems that utilize -// `systemd-resolved` for managing DNS configurations. -// - It relies on the `resolvectl` command, which is available on systems with -// `systemd` version 237 or later. On non-systemd systems or older versions of -// Linux, this functionality may not be available. -// -// Notes about the implementation: -// - This function queries DNS information dynamically using `resolvectl`, which -// supports per-interface configurations and reflects the live state of DNS -// settings managed by `systemd-resolved`. -// - If no search domains are configured for the interface, the function defaults -// to returning `["."]` to indicate the root domain. -// -// Requirements: -// - The `resolvectl` command must be installed and available in the system path. -// - The caller must have sufficient privileges to query network settings for the -// specified interface. +// UpdateResolvConfByInterface updates the DNS configuration for a specific +// network interface by generating a Netplan drop-in file and applying it. +// The function preserves existing settings for values that are not specified +// and delegates idempotency to the Netplan state tracker. // -// See `systemd-resolved.service(8)` manual page for further information. +// The read path still uses resolvectl to query current DNS state. The write +// path generates a Netplan YAML file under /etc/netplan/osapi-dns.yaml and +// applies it via `netplan generate` + `netplan apply`. func (u *Debian) UpdateResolvConfByInterface( servers []string, searchDomains []string, interfaceName string, ) (*UpdateResult, error) { u.logger.Info( - "setting resolvectl configuration", + "setting dns configuration via netplan", slog.String("servers", strings.Join(servers, ", ")), slog.String("search_domains", strings.Join(searchDomains, ", ")), ) @@ -72,7 +57,7 @@ func (u *Debian) UpdateResolvConfByInterface( return nil, fmt.Errorf("failed to get current resolvectl configuration: %w", err) } - // Use existing values if new values are not provided + // Use existing values if new values are not provided. if len(servers) == 0 { servers = existingConfig.DNSServers } @@ -80,45 +65,39 @@ func (u *Debian) UpdateResolvConfByInterface( searchDomains = existingConfig.SearchDomains } - // Compare desired config against existing to detect no-op - if slices.Equal(servers, existingConfig.DNSServers) && - slices.Equal(searchDomains, existingConfig.SearchDomains) { - u.logger.Info("dns configuration unchanged, skipping update") - return &UpdateResult{Changed: false}, nil - } - - // Set DNS servers - if len(servers) > 0 { - cmd := "resolvectl" - args := append([]string{"dns", interfaceName}, servers...) - output, err := u.execManager.RunPrivilegedCmd(cmd, args) - if err != nil { - return nil, fmt.Errorf( - "failed to set DNS servers with resolvectl: %w - %s", - err, - output, - ) - } - } - - filteredDomains := []string{} + // Filter out root domain marker before generating YAML. + filteredDomains := make([]string, 0, len(searchDomains)) for _, domain := range searchDomains { if domain != "." { filteredDomains = append(filteredDomains, domain) } } - if len(filteredDomains) > 0 { - cmd := "resolvectl" - args := append([]string{"domain", interfaceName}, filteredDomains...) - output, err := u.execManager.RunPrivilegedCmd(cmd, args) - if err != nil { - return nil, fmt.Errorf( - "failed to set search domains with resolvectl: %w - %s", - err, - output, - ) - } + + // Resolve the interface name for the Netplan config. + resolvedInterface := u.resolvePrimaryInterface(interfaceName) + + // Generate the Netplan YAML content. + content := generateDNSNetplanYAML(resolvedInterface, servers, filteredDomains) + + // Apply via the shared Netplan helper (handles write, validate, + // apply, and KV state tracking with SHA-based idempotency). + changed, applyErr := netplan.ApplyConfig( + context.TODO(), + u.logger, + u.fs, + u.stateKV, + u.execManager, + u.hostname, + dnsNetplanPath(), + content, + map[string]string{ + "domain": "dns", + "interface": resolvedInterface, + }, + ) + if applyErr != nil { + return nil, fmt.Errorf("dns update via netplan: %w", applyErr) } - return &UpdateResult{Changed: true}, nil + return &UpdateResult{Changed: changed}, nil } diff --git a/internal/provider/network/dns/debian_update_resolv_conf_by_interface_public_test.go b/internal/provider/network/dns/debian_update_resolv_conf_by_interface_public_test.go index 88d4dfbdf..00b52f483 100644 --- a/internal/provider/network/dns/debian_update_resolv_conf_by_interface_public_test.go +++ b/internal/provider/network/dns/debian_update_resolv_conf_by_interface_public_test.go @@ -21,16 +21,18 @@ package dns_test import ( - "fmt" + "errors" "log/slog" "os" "testing" + "github.com/avfs/avfs/vfs/memfs" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "github.com/retr0h/osapi/internal/exec/mocks" + execmocks "github.com/retr0h/osapi/internal/exec/mocks" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/provider/network/dns" ) @@ -58,20 +60,31 @@ func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TearDownTest() { func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvConfByInterface() { tests := []struct { name string - setupMock func() *mocks.MockManager + setupMock func() (*execmocks.MockManager, *jobmocks.MockKeyValue) servers []string searchDomains []string interfaceName string - want *dns.GetResult + wantChanged bool wantErr bool - wantErrType error + wantErrMsg string }{ { name: "when SetResolvConf Ok", - setupMock: func() *mocks.MockManager { - mock := mocks.NewSetResolvConfMockManager(suite.ctrl) + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewSetResolvConfMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) - return mock + // KV Get returns not found (new file). + kv.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + // KV Put succeeds. + kv.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(1), nil) + + return mock, kv }, interfaceName: "wlp0s20f3", servers: []string{ @@ -82,114 +95,105 @@ func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC "foo.local", "bar.local", }, - want: &dns.GetResult{ - DNSServers: []string{ - "8.8.8.8", - "9.9.9.9", - }, - SearchDomains: []string{ - "foo.local", - "bar.local", - }, - }, - wantErr: false, + wantChanged: true, + wantErr: false, }, { name: "when SetResolvConf preserves existing servers Ok", - setupMock: func() *mocks.MockManager { - mock := mocks.NewSetResolvConfPreserveDNSServersMockManager(suite.ctrl) + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewSetResolvConfPreserveDNSServersMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) + + kv.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + kv.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(1), nil) - return mock + return mock, kv }, interfaceName: "wlp0s20f3", searchDomains: []string{ "foo.local", "bar.local", }, - want: &dns.GetResult{ - DNSServers: []string{ - "1.1.1.1", - "2.2.2.2", - }, - SearchDomains: []string{ - "foo.local", - "bar.local", - }, - }, - wantErr: false, + wantChanged: true, + wantErr: false, }, { name: "when SetResolvConf preserves existing search domains Ok", - setupMock: func() *mocks.MockManager { - mock := mocks.NewSetResolvConfPreserveDNSDomainMockManager(suite.ctrl) + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewSetResolvConfPreserveDNSDomainMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) + + kv.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) - return mock + kv.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(1), nil) + + return mock, kv }, interfaceName: "wlp0s20f3", servers: []string{ "8.8.8.8", "9.9.9.9", }, - want: &dns.GetResult{ - DNSServers: []string{ - "8.8.8.8", - "9.9.9.9", - }, - SearchDomains: []string{ - "foo.example.com", - "bar.example.com", - }, - }, - wantErr: false, + wantChanged: true, + wantErr: false, }, - // dewey { name: "when SetResolvConf filters root domain Ok", - setupMock: func() *mocks.MockManager { - mock := mocks.NewSetResolvConfFiltersRootDNSDomainMockManager(suite.ctrl) + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewSetResolvConfFiltersRootDNSDomainMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) - return mock + kv.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + kv.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(1), nil) + + return mock, kv }, interfaceName: "wlp0s20f3", servers: []string{ "8.8.8.8", "9.9.9.9", }, - want: &dns.GetResult{ - DNSServers: []string{ - "8.8.8.8", - "9.9.9.9", - }, - SearchDomains: []string{ - ".", - }, - }, - wantErr: false, + wantChanged: true, + wantErr: false, }, { name: "when SetResolvConf missing args errors", wantErr: true, - setupMock: func() *mocks.MockManager { - mock := mocks.NewPlainMockManager(suite.ctrl) + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewPlainMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) - return mock + return mock, kv }, interfaceName: "wlp0s20f3", - wantErrType: fmt.Errorf( - "no DNS servers or search domains provided; nothing to update", - ), + wantErrMsg: "no DNS servers or search domains provided; nothing to update", }, { name: "when GetResolvConfByInterface errors", - setupMock: func() *mocks.MockManager { - mock := mocks.NewPlainMockManager(suite.ctrl) + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewPlainMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) mock.EXPECT(). - RunCmd(mocks.ResolveCommand, []string{"status", mocks.NetworkInterfaceName}). + RunCmd(execmocks.ResolveCommand, []string{"status", execmocks.NetworkInterfaceName}). Return("", assert.AnError). AnyTimes() - return mock + return mock, kv }, interfaceName: "wlp0s20f3", servers: []string{ @@ -200,15 +204,24 @@ func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC "foo.local", "bar.local", }, - wantErr: true, - wantErrType: assert.AnError, + wantErr: true, + wantErrMsg: assert.AnError.Error(), }, { - name: "when exec.RunCmd setting DNS Domain errors", - setupMock: func() *mocks.MockManager { - mock := mocks.NewSetResolvConfSetDNSDomainErrorMockManager(suite.ctrl) + name: "when netplan generate fails", + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewSetResolvConfNetplanGenerateErrorMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) + + kv.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) - return mock + mock.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", errors.New("invalid YAML")) + + return mock, kv }, interfaceName: "wlp0s20f3", servers: []string{ @@ -219,15 +232,28 @@ func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC "foo.local", "bar.local", }, - wantErr: true, - wantErrType: assert.AnError, + wantErr: true, + wantErrMsg: "netplan validate failed (file rolled back)", }, { - name: "when exec.RunCmd setting DNS Servers errors", - setupMock: func() *mocks.MockManager { - mock := mocks.NewSetResolvConfSetDNSServersErrorMockManager(suite.ctrl) + name: "when netplan apply fails", + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewSetResolvConfNetplanGenerateErrorMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) - return mock + kv.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + mock.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", nil) + + mock.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", errors.New("apply failed")) + + return mock, kv }, interfaceName: "wlp0s20f3", servers: []string{ @@ -238,16 +264,43 @@ func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC "foo.local", "bar.local", }, - wantErr: true, - wantErrType: assert.AnError, + wantErr: true, + wantErrMsg: "netplan apply:", + }, + { + name: "when interface resolved from facts", + setupMock: func() (*execmocks.MockManager, *jobmocks.MockKeyValue) { + mock := execmocks.NewPlainMockManager(suite.ctrl) + kv := jobmocks.NewMockKeyValue(suite.ctrl) + + // Read path uses empty interface name, but resolvectl + // still needs the interface. The handler passes the + // resolved interface to GetResolvConfByInterface before + // calling update. For this test the read path errors + // since we pass empty interface to resolvectl. + mock.EXPECT(). + RunCmd(execmocks.ResolveCommand, []string{"status", ""}). + Return("", assert.AnError). + AnyTimes() + + return mock, kv + }, + interfaceName: "", + servers: []string{ + "8.8.8.8", + }, + wantErr: true, + wantErrMsg: "failed to get current resolvectl configuration", }, } for _, tc := range tests { suite.Run(tc.name, func() { - mock := tc.setupMock() + mock, kv := tc.setupMock() + fs := memfs.New() + _ = fs.MkdirAll("/etc/netplan", 0o755) - net := dns.NewDebianProvider(suite.logger, mock) + net := dns.NewDebianProvider(suite.logger, fs, kv, mock, "test-host") result, err := net.UpdateResolvConfByInterface( tc.servers, tc.searchDomains, @@ -257,14 +310,11 @@ func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC if tc.wantErr { suite.Error(err) suite.Nil(result) - suite.Contains(err.Error(), tc.wantErrType.Error()) + suite.Contains(err.Error(), tc.wantErrMsg) } else { suite.NoError(err) suite.NotNil(result) - - got, err := net.GetResolvConfByInterface(tc.interfaceName) - suite.Equal(tc.want, got) - suite.NoError(err) + suite.Equal(tc.wantChanged, result.Changed) } }) } diff --git a/internal/provider/network/dns/export_test.go b/internal/provider/network/dns/export_test.go new file mode 100644 index 000000000..7476f365e --- /dev/null +++ b/internal/provider/network/dns/export_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package dns + +// ExportGenerateDNSNetplanYAML exposes generateDNSNetplanYAML for testing. +func ExportGenerateDNSNetplanYAML( + interfaceName string, + servers []string, + searchDomains []string, +) []byte { + return generateDNSNetplanYAML(interfaceName, servers, searchDomains) +} + +// ExportDNSNetplanPath exposes dnsNetplanPath for testing. +func ExportDNSNetplanPath() string { + return dnsNetplanPath() +} + +// ExportResolvePrimaryInterface exposes resolvePrimaryInterface for testing. +func (d *Debian) ExportResolvePrimaryInterface( + interfaceName string, +) string { + return d.resolvePrimaryInterface(interfaceName) +} diff --git a/internal/provider/network/netplan/export_test.go b/internal/provider/network/netplan/export_test.go new file mode 100644 index 000000000..c9a0bec9d --- /dev/null +++ b/internal/provider/network/netplan/export_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package netplan + +import "encoding/json" + +// SetMarshalJSON overrides the marshal function for testing. +func SetMarshalJSON(fn func(interface{}) ([]byte, error)) { + marshalJSON = fn +} + +// ResetMarshalJSON restores the default marshal function. +func ResetMarshalJSON() { + marshalJSON = json.Marshal +} diff --git a/internal/provider/network/netplan/netplan.go b/internal/provider/network/netplan/netplan.go new file mode 100644 index 000000000..45b47463a --- /dev/null +++ b/internal/provider/network/netplan/netplan.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// Package netplan provides shared helpers for writing Netplan configuration +// files with validation, rollback, and file-state tracking. +package netplan + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "path/filepath" + "time" + + "github.com/avfs/avfs" + "github.com/nats-io/nats.go/jetstream" + + "github.com/retr0h/osapi/internal/exec" + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/file" +) + +// marshalJSON is a package-level variable for testing the marshal error path. +var marshalJSON = json.Marshal + +// ApplyConfig writes a Netplan configuration file to disk, validates it +// with `netplan generate`, applies it with `netplan apply`, and tracks +// the file state in the KV store. Returns (true, nil) when the file was +// written and applied, (false, nil) when the content is unchanged. +func ApplyConfig( + ctx context.Context, + logger *slog.Logger, + fs avfs.VFS, + stateKV jetstream.KeyValue, + execManager exec.Manager, + hostname string, + path string, + content []byte, + metadata map[string]string, +) (bool, error) { + sha := ComputeSHA256(content) + stateKey := file.BuildStateKey(hostname, path) + + // Check for idempotency: if SHA matches and file exists, skip. + kvEntry, err := stateKV.Get(ctx, stateKey) + if err == nil { + var state job.FileState + if unmarshalErr := json.Unmarshal(kvEntry.Value(), &state); unmarshalErr == nil { + if state.SHA256 == sha && state.UndeployedAt == "" { + if _, statErr := fs.Stat(path); statErr == nil { + logger.Debug( + "netplan config unchanged, skipping deploy", + slog.String("path", path), + ) + + return false, nil + } + } + } + } + + // Ensure the parent directory exists. + dir := filepath.Dir(path) + if mkErr := fs.MkdirAll(dir, 0o755); mkErr != nil { + return false, fmt.Errorf("netplan apply: create directory: %w", mkErr) + } + + // Write the config file. + if writeErr := fs.WriteFile(path, content, 0o644); writeErr != nil { + return false, fmt.Errorf("netplan apply: write file: %w", writeErr) + } + + // Validate with netplan generate (validates without applying). + if _, genErr := execManager.RunPrivilegedCmd("netplan", []string{"generate"}); genErr != nil { + // Roll back: remove the invalid file. + _ = fs.Remove(path) + + return false, fmt.Errorf( + "netplan validate failed (file rolled back): %w", + genErr, + ) + } + + // Apply the configuration. + if _, applyErr := execManager.RunPrivilegedCmd("netplan", []string{"apply"}); applyErr != nil { + return false, fmt.Errorf("netplan apply: %w", applyErr) + } + + // Persist state in KV. + state := job.FileState{ + Path: path, + SHA256: sha, + Mode: "0644", + DeployedAt: time.Now().UTC().Format(time.RFC3339), + Metadata: metadata, + } + + stateBytes, marshalErr := marshalJSON(state) + if marshalErr != nil { + return false, fmt.Errorf("netplan apply: marshal state: %w", marshalErr) + } + + if _, putErr := stateKV.Put(ctx, stateKey, stateBytes); putErr != nil { + return false, fmt.Errorf("netplan apply: update state: %w", putErr) + } + + logger.Info( + "netplan config deployed", + slog.String("path", path), + slog.String("sha256", sha), + ) + + return true, nil +} + +// RemoveConfig removes a Netplan configuration file from disk, applies +// the change with `netplan apply`, and marks the file state as undeployed +// in the KV store. Returns (true, nil) when the file was removed and +// applied, (false, nil) when the file does not exist. +func RemoveConfig( + ctx context.Context, + logger *slog.Logger, + fs avfs.VFS, + stateKV jetstream.KeyValue, + execManager exec.Manager, + hostname string, + path string, +) (bool, error) { + // Check if file exists — if not, nothing to do. + if _, err := fs.Stat(path); err != nil { + return false, nil + } + + // Remove the file. + if removeErr := fs.Remove(path); removeErr != nil { + return false, fmt.Errorf("netplan remove: remove file: %w", removeErr) + } + + // Apply the configuration change. + if _, applyErr := execManager.RunPrivilegedCmd("netplan", []string{"apply"}); applyErr != nil { + return false, fmt.Errorf("netplan remove: apply: %w", applyErr) + } + + // Best-effort state cleanup: mark as undeployed in KV. + stateKey := file.BuildStateKey(hostname, path) + + kvEntry, err := stateKV.Get(ctx, stateKey) + if err == nil { + var state job.FileState + if unmarshalErr := json.Unmarshal(kvEntry.Value(), &state); unmarshalErr == nil { + state.UndeployedAt = time.Now().UTC().Format(time.RFC3339) + + stateBytes, marshalErr := marshalJSON(state) + if marshalErr == nil { + if _, putErr := stateKV.Put(ctx, stateKey, stateBytes); putErr != nil { + logger.Warn( + "netplan remove: failed to update state", + slog.String("path", path), + slog.String("error", putErr.Error()), + ) + } + } + } + } + + logger.Info( + "netplan config removed", + slog.String("path", path), + ) + + return true, nil +} + +// ComputeSHA256 returns the hex-encoded SHA-256 hash of the given data. +func ComputeSHA256( + data []byte, +) string { + h := sha256.Sum256(data) + + return hex.EncodeToString(h[:]) +} diff --git a/internal/provider/network/netplan/netplan_public_test.go b/internal/provider/network/netplan/netplan_public_test.go new file mode 100644 index 000000000..d369efd65 --- /dev/null +++ b/internal/provider/network/netplan/netplan_public_test.go @@ -0,0 +1,579 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package netplan_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "log/slog" + "os" + "testing" + + "github.com/avfs/avfs" + "github.com/avfs/avfs/vfs/failfs" + "github.com/avfs/avfs/vfs/memfs" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + execmocks "github.com/retr0h/osapi/internal/exec/mocks" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/provider/network/netplan" +) + +const ( + testHostname = "test-host" + testPath = "/etc/netplan/99-osapi-dns.yaml" +) + +var testContent = []byte( + "network:\n ethernets:\n eth0:\n nameservers:\n addresses:\n - 8.8.8.8\n", +) + +func testSHA() string { + h := sha256.Sum256(testContent) + + return hex.EncodeToString(h[:]) +} + +type NetplanPublicTestSuite struct { + suite.Suite + + ctrl *gomock.Controller + ctx context.Context + logger *slog.Logger + memFs avfs.VFS + mockStateKV *jobmocks.MockKeyValue + mockExec *execmocks.MockManager +} + +func (suite *NetplanPublicTestSuite) SetupTest() { + suite.ctrl = gomock.NewController(suite.T()) + suite.ctx = context.Background() + suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) + suite.memFs = memfs.New() + suite.mockStateKV = jobmocks.NewMockKeyValue(suite.ctrl) + suite.mockExec = execmocks.NewMockManager(suite.ctrl) + + _ = suite.memFs.MkdirAll("/etc/netplan", 0o755) +} + +func (suite *NetplanPublicTestSuite) SetupSubTest() { + suite.SetupTest() +} + +func (suite *NetplanPublicTestSuite) TearDownSubTest() { + netplan.ResetMarshalJSON() +} + +func (suite *NetplanPublicTestSuite) TestApplyConfig() { + tests := []struct { + name string + setup func() + validateFunc func(bool, error) + }{ + { + name: "when new file deploys successfully", + setup: func() { + // KV Get returns not found (new file). + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + // netplan generate succeeds. + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", nil) + + // netplan apply succeeds. + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", nil) + + // KV Put succeeds. + suite.mockStateKV.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(1), nil) + }, + validateFunc: func(changed bool, err error) { + suite.Require().NoError(err) + suite.True(changed) + + // Verify file was written. + data, readErr := suite.memFs.ReadFile(testPath) + suite.Require().NoError(readErr) + suite.Equal(testContent, data) + }, + }, + { + name: "when SHA matches and file exists (idempotent)", + setup: func() { + // Write the file so it exists on disk. + _ = suite.memFs.WriteFile(testPath, testContent, 0o644) + + state := job.FileState{ + Path: testPath, + SHA256: testSHA(), + Mode: "0644", + DeployedAt: "2026-01-01T00:00:00Z", + } + stateBytes, _ := json.Marshal(state) + + mockEntry := jobmocks.NewMockKeyValueEntry(suite.ctrl) + mockEntry.EXPECT().Value().Return(stateBytes) + + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(mockEntry, nil) + }, + validateFunc: func(changed bool, err error) { + suite.Require().NoError(err) + suite.False(changed) + }, + }, + { + name: "when SHA matches but file missing (rewrites)", + setup: func() { + // File does NOT exist on disk (not pre-created). + state := job.FileState{ + Path: testPath, + SHA256: testSHA(), + Mode: "0644", + DeployedAt: "2026-01-01T00:00:00Z", + } + stateBytes, _ := json.Marshal(state) + + mockEntry := jobmocks.NewMockKeyValueEntry(suite.ctrl) + mockEntry.EXPECT().Value().Return(stateBytes) + + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(mockEntry, nil) + + // Proceeds to write, generate, apply, put. + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", nil) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", nil) + + suite.mockStateKV.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(1), nil) + }, + validateFunc: func(changed bool, err error) { + suite.Require().NoError(err) + suite.True(changed) + }, + }, + { + name: "when netplan generate fails (rolls back file)", + setup: func() { + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", errors.New("invalid YAML")) + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan validate failed (file rolled back)") + + // Verify file was removed. + _, statErr := suite.memFs.Stat(testPath) + suite.Error(statErr) + }, + }, + { + name: "when netplan apply fails", + setup: func() { + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", nil) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", errors.New("apply failed")) + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan apply:") + }, + }, + { + name: "when write file fails", + setup: func() { + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + // Use failfs to block writes. + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc/netplan", 0o755) + + vfs := failfs.New(baseFs) + _ = vfs.SetFailFunc(func( + _ avfs.VFSBase, + fn avfs.FnVFS, + _ *failfs.FailParam, + ) error { + if fn == avfs.FnOpenFile { + return errors.New("write failed") + } + + return nil + }) + suite.memFs = vfs + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan apply: write file:") + }, + }, + { + name: "when mkdir fails", + setup: func() { + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + // Use failfs to block MkdirAll. + baseFs := memfs.New() + vfs := failfs.New(baseFs) + _ = vfs.SetFailFunc(func( + _ avfs.VFSBase, + fn avfs.FnVFS, + _ *failfs.FailParam, + ) error { + if fn == avfs.FnMkdirAll { + return errors.New("mkdir failed") + } + + return nil + }) + suite.memFs = vfs + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan apply: create directory:") + }, + }, + { + name: "when state put fails", + setup: func() { + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", nil) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", nil) + + suite.mockStateKV.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(0), errors.New("kv put failed")) + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan apply: update state:") + }, + }, + { + name: "when marshal state fails", + setup: func() { + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("not found")) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"generate"}). + Return("", nil) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", nil) + + netplan.SetMarshalJSON(func(_ interface{}) ([]byte, error) { + return nil, errors.New("marshal failed") + }) + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan apply: marshal state:") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.setup() + + changed, err := netplan.ApplyConfig( + suite.ctx, + suite.logger, + suite.memFs, + suite.mockStateKV, + suite.mockExec, + testHostname, + testPath, + testContent, + nil, + ) + + tc.validateFunc(changed, err) + }) + } +} + +func (suite *NetplanPublicTestSuite) TestRemoveConfig() { + tests := []struct { + name string + setup func() + validateFunc func(bool, error) + }{ + { + name: "when file exists and removal succeeds", + setup: func() { + // Create the file on disk. + _ = suite.memFs.WriteFile(testPath, testContent, 0o644) + + // netplan apply succeeds. + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", nil) + + // KV state exists for undeploy marking. + state := job.FileState{ + Path: testPath, + SHA256: testSHA(), + Mode: "0644", + DeployedAt: "2026-01-01T00:00:00Z", + } + stateBytes, _ := json.Marshal(state) + + mockEntry := jobmocks.NewMockKeyValueEntry(suite.ctrl) + mockEntry.EXPECT().Value().Return(stateBytes) + + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(mockEntry, nil) + + suite.mockStateKV.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(1), nil) + }, + validateFunc: func(changed bool, err error) { + suite.Require().NoError(err) + suite.True(changed) + + // Verify file was removed. + _, statErr := suite.memFs.Stat(testPath) + suite.Error(statErr) + }, + }, + { + name: "when file does not exist", + setup: func() { + // No file on disk — nothing to do. + }, + validateFunc: func(changed bool, err error) { + suite.Require().NoError(err) + suite.False(changed) + }, + }, + { + name: "when remove fails", + setup: func() { + // Create the file on disk, then use failfs to block Remove. + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc/netplan", 0o755) + _ = baseFs.WriteFile(testPath, testContent, 0o644) + + vfs := failfs.New(baseFs) + _ = vfs.SetFailFunc(func( + _ avfs.VFSBase, + fn avfs.FnVFS, + _ *failfs.FailParam, + ) error { + if fn == avfs.FnRemove { + return errors.New("remove failed") + } + + return nil + }) + suite.memFs = vfs + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan remove: remove file:") + }, + }, + { + name: "when netplan apply fails after remove", + setup: func() { + _ = suite.memFs.WriteFile(testPath, testContent, 0o644) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", errors.New("apply failed")) + }, + validateFunc: func(changed bool, err error) { + suite.Require().Error(err) + suite.False(changed) + suite.Contains(err.Error(), "netplan remove: apply:") + }, + }, + { + name: "when state KV put fails on undeploy (best-effort)", + setup: func() { + _ = suite.memFs.WriteFile(testPath, testContent, 0o644) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", nil) + + state := job.FileState{ + Path: testPath, + SHA256: testSHA(), + Mode: "0644", + DeployedAt: "2026-01-01T00:00:00Z", + } + stateBytes, _ := json.Marshal(state) + + mockEntry := jobmocks.NewMockKeyValueEntry(suite.ctrl) + mockEntry.EXPECT().Value().Return(stateBytes) + + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(mockEntry, nil) + + suite.mockStateKV.EXPECT(). + Put(gomock.Any(), gomock.Any(), gomock.Any()). + Return(uint64(0), errors.New("kv put failed")) + }, + validateFunc: func(changed bool, err error) { + // Best-effort: should succeed despite KV error. + suite.Require().NoError(err) + suite.True(changed) + }, + }, + { + name: "when state KV get fails on undeploy (best-effort)", + setup: func() { + _ = suite.memFs.WriteFile(testPath, testContent, 0o644) + + suite.mockExec.EXPECT(). + RunPrivilegedCmd("netplan", []string{"apply"}). + Return("", nil) + + suite.mockStateKV.EXPECT(). + Get(gomock.Any(), gomock.Any()). + Return(nil, errors.New("kv get failed")) + }, + validateFunc: func(changed bool, err error) { + // Best-effort: should succeed despite KV error. + suite.Require().NoError(err) + suite.True(changed) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.setup() + + changed, err := netplan.RemoveConfig( + suite.ctx, + suite.logger, + suite.memFs, + suite.mockStateKV, + suite.mockExec, + testHostname, + testPath, + ) + + tc.validateFunc(changed, err) + }) + } +} + +func (suite *NetplanPublicTestSuite) TestComputeSHA256() { + tests := []struct { + name string + input []byte + validateFunc func(string) + }{ + { + name: "when computing known hash", + input: []byte("hello world"), + validateFunc: func(result string) { + h := sha256.Sum256([]byte("hello world")) + expected := hex.EncodeToString(h[:]) + suite.Equal(expected, result) + }, + }, + { + name: "when computing empty input hash", + input: []byte(""), + validateFunc: func(result string) { + h := sha256.Sum256([]byte("")) + expected := hex.EncodeToString(h[:]) + suite.Equal(expected, result) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := netplan.ComputeSHA256(tc.input) + + tc.validateFunc(result) + }) + } +} + +func TestNetplanPublicTestSuite(t *testing.T) { + t.Parallel() + + suite.Run(t, new(NetplanPublicTestSuite)) +}