From d335da701c03366a3a53f9e21337b1fb82e12e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 21:43:02 -0700 Subject: [PATCH 1/8] feat(netplan): add shared ApplyConfig and RemoveConfig helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable netplan package with write-validate-apply flow, rollback on validation failure, and file-state KV tracking. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../provider/network/netplan/export_test.go | 33 + internal/provider/network/netplan/netplan.go | 201 ++++++ .../network/netplan/netplan_public_test.go | 577 ++++++++++++++++++ 3 files changed, 811 insertions(+) create mode 100644 internal/provider/network/netplan/export_test.go create mode 100644 internal/provider/network/netplan/netplan.go create mode 100644 internal/provider/network/netplan/netplan_public_test.go 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..e3dec6ef8 --- /dev/null +++ b/internal/provider/network/netplan/netplan_public_test.go @@ -0,0 +1,577 @@ +// 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)) +} From fe6ee24a7083a7e4a8813f0dda6059c9e399e072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 21:46:12 -0700 Subject: [PATCH 2/8] refactor(dns): add fs, stateKV, hostname to Debian provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add avfs.VFS, jetstream.KeyValue, and hostname fields to the DNS Debian provider struct and constructor. These dependencies are needed for upcoming Netplan DNS write support. Move file provider creation earlier in agent_setup.go so fileStateKV and hostname are available when the DNS provider is constructed. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/agent_setup.go | 10 +++++----- internal/provider/network/dns/debian.go | 12 ++++++++++++ ...ebian_get_resolv_conf_by_interface_public_test.go | 3 ++- ...an_update_resolv_conf_by_interface_public_test.go | 3 ++- 4 files changed, 21 insertions(+), 7 deletions(-) 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/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_update_resolv_conf_by_interface_public_test.go b/internal/provider/network/dns/debian_update_resolv_conf_by_interface_public_test.go index 88d4dfbdf..41fcbbb68 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 @@ -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" @@ -247,7 +248,7 @@ func (suite *DebianUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC 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") result, err := net.UpdateResolvConfByInterface( tc.servers, tc.searchDomains, From b894999bff230bd4716df6ea298d5f1fe3ae26b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 21:52:19 -0700 Subject: [PATCH 3/8] feat(dns): migrate writes from resolvectl to Netplan Replace the DNS write path (UpdateResolvConfByInterface) from resolvectl commands to Netplan YAML generation. The read path still uses resolvectl for querying current DNS state. Writes now generate /etc/netplan/osapi-dns.yaml and apply via the shared netplan.ApplyConfig helper with SHA-based idempotency. Co-Authored-By: Claude --- internal/exec/mocks/dns.go | 158 ++++-------- .../provider/network/dns/debian_netplan.go | 86 +++++++ .../debian_update_resolv_conf_by_interface.go | 101 +++----- ...te_resolv_conf_by_interface_public_test.go | 233 +++++++++++------- 4 files changed, 316 insertions(+), 262 deletions(-) create mode 100644 internal/provider/network/dns/debian_netplan.go 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_netplan.go b/internal/provider/network/dns/debian_netplan.go new file mode 100644 index 000000000..f87b2ffad --- /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") + b.WriteString(fmt.Sprintf(" %s:\n", interfaceName)) + b.WriteString(" nameservers:\n") + + if len(servers) > 0 { + b.WriteString(" addresses:\n") + for _, s := range servers { + b.WriteString(fmt.Sprintf(" - %s\n", s)) + } + } + + if len(searchDomains) > 0 { + b.WriteString(" search:\n") + for _, d := range searchDomains { + b.WriteString(fmt.Sprintf(" - %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_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 41fcbbb68..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,7 +21,7 @@ package dns_test import ( - "fmt" + "errors" "log/slog" "os" "testing" @@ -31,7 +31,8 @@ import ( "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" ) @@ -59,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{ @@ -83,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{ @@ -201,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{ @@ -220,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{ @@ -239,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, memfs.New(), nil, mock, "test-host") + net := dns.NewDebianProvider(suite.logger, fs, kv, mock, "test-host") result, err := net.UpdateResolvConfByInterface( tc.servers, tc.searchDomains, @@ -258,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) } }) } From f990014ef20fc4251ad34411ce34ffd05ad8479e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 21:54:40 -0700 Subject: [PATCH 4/8] test(dns): add Netplan YAML generation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add export bridge and public test suite for generateDNSNetplanYAML, dnsNetplanPath, and resolvePrimaryInterface covering servers-only, search-domains-only, combined, IPv6, and facts-based interface resolution scenarios. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../network/dns/debian_netplan_public_test.go | 214 ++++++++++++++++++ internal/provider/network/dns/export_test.go | 42 ++++ 2 files changed, 256 insertions(+) create mode 100644 internal/provider/network/dns/debian_netplan_public_test.go create mode 100644 internal/provider/network/dns/export_test.go 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/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) +} From 264ac62412c3944572391f695ac37447d54d1d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 21:57:58 -0700 Subject: [PATCH 5/8] feat(dns): migrate writes from resolvectl to persistent Netplan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DNS write operations now generate /etc/netplan/osapi-dns.yaml, validate with netplan generate, and apply with netplan apply. Reads stay on resolvectl. Shared netplan.ApplyConfig helper established for interface and route management to reuse. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/network-management.md | 6 ++++-- internal/provider/network/dns/debian_netplan.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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/internal/provider/network/dns/debian_netplan.go b/internal/provider/network/dns/debian_netplan.go index f87b2ffad..38e99d5c0 100644 --- a/internal/provider/network/dns/debian_netplan.go +++ b/internal/provider/network/dns/debian_netplan.go @@ -44,20 +44,20 @@ func generateDNSNetplanYAML( b.WriteString("network:\n") b.WriteString(" version: 2\n") b.WriteString(" ethernets:\n") - b.WriteString(fmt.Sprintf(" %s:\n", interfaceName)) + fmt.Fprintf(&b, " %s:\n", interfaceName) b.WriteString(" nameservers:\n") if len(servers) > 0 { b.WriteString(" addresses:\n") for _, s := range servers { - b.WriteString(fmt.Sprintf(" - %s\n", s)) + fmt.Fprintf(&b, " - %s\n", s) } } if len(searchDomains) > 0 { b.WriteString(" search:\n") for _, d := range searchDomains { - b.WriteString(fmt.Sprintf(" - %s\n", d)) + fmt.Fprintf(&b, " - %s\n", d) } } From 590786c81d544b50edfffd25ca209ac0414653f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 22:05:39 -0700 Subject: [PATCH 6/8] style(netplan): wrap long line in test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/provider/network/netplan/netplan_public_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/provider/network/netplan/netplan_public_test.go b/internal/provider/network/netplan/netplan_public_test.go index e3dec6ef8..d369efd65 100644 --- a/internal/provider/network/netplan/netplan_public_test.go +++ b/internal/provider/network/netplan/netplan_public_test.go @@ -47,7 +47,9 @@ const ( 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") +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) From 5a254b7e2c9c18b6f367cb55903ec38b80dde55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 22:12:43 -0700 Subject: [PATCH 7/8] docs: clarify fact references work in any API string field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the supported contexts section to explain that fact resolution is universal — any string in any request gets resolved agent-side. Add array and broadcast examples. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/system-facts.md | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/docs/sidebar/features/system-facts.md b/docs/docs/sidebar/features/system-facts.md index 31bad7091..365b00868 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. From 6077ffb32d950a71f33b95382c68f70d0d833bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 22:14:12 -0700 Subject: [PATCH 8/8] style(docs): reflow system facts line wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/system-facts.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/sidebar/features/system-facts.md b/docs/docs/sidebar/features/system-facts.md index 365b00868..daf2ab5e1 100644 --- a/docs/docs/sidebar/features/system-facts.md +++ b/docs/docs/sidebar/features/system-facts.md @@ -120,16 +120,16 @@ which reference could not be resolved. ### Supported Contexts -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 +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. 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 +- **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.