From 62ff4e782bc057d277a7c41fa35a665e2aeca045 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 6 Jul 2026 23:56:32 +0300 Subject: initial package management --- internal/exec/exec.go | 38 ++++++++++++++++++++ internal/exec/exec_test.go | 69 +++++++++++++++++++++++++++++++++++++ internal/resource/dir/dir.go | 2 +- internal/resource/dir/dir_test.go | 10 +++--- internal/resource/file/file.go | 2 +- internal/resource/file/file_test.go | 2 +- internal/resource/link/link.go | 2 +- internal/resource/link/link_test.go | 2 +- internal/resource/pkg/dnf.go | 34 ++++++++++++++++++ internal/resource/pkg/pkg.go | 47 +++++++++++++++++++++++++ 10 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 internal/exec/exec.go create mode 100644 internal/exec/exec_test.go create mode 100644 internal/resource/pkg/dnf.go create mode 100644 internal/resource/pkg/pkg.go (limited to 'internal') diff --git a/internal/exec/exec.go b/internal/exec/exec.go new file mode 100644 index 0000000..87e861b --- /dev/null +++ b/internal/exec/exec.go @@ -0,0 +1,38 @@ +package exec + +import ( + "bytes" + "os/exec" +) + +// Run executes a shell command with the given arguments and returns stdout, stderr, exit code, and any error encountered. +func Run(name string, args ...string) (stdout, stderr string, exitCode int, err error) { + cmd := exec.Command(name, args...) + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err = cmd.Run() + + stdout = stdoutBuf.String() + stderr = stderrBuf.String() + + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + // In this case, the error is just the non-zero exit code, + // which we've already captured. We return nil for err to indicate + // that the command actually ran and exited (even if non-zero). + err = nil + } else { + // This is a "real" error, e.g., binary not found + exitCode = -1 + return stdout, stderr, exitCode, err + } + } else { + exitCode = 0 + } + + return stdout, stderr, exitCode, nil +} diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go new file mode 100644 index 0000000..4dc6505 --- /dev/null +++ b/internal/exec/exec_test.go @@ -0,0 +1,69 @@ +package exec + +import ( + "testing" +) + +func TestRun(t *testing.T) { + tests := []struct { + name string + cmd string + args []string + wantStdout string + wantStderr string + wantExitCode int + wantErr bool + }{ + { + name: "success", + cmd: "echo", + args: []string{"hello world"}, + wantStdout: "hello world\n", + wantStderr: "", + wantExitCode: 0, + wantErr: false, + }, + { + name: "fail-exit-code", + cmd: "ls", + args: []string{"/non-existent-directory-12345"}, + wantStdout: "", + wantStderr: "", // ls stderr varies by OS, but should not be empty usually. + wantExitCode: 2, // Typical for ls non-existent + wantErr: false, + }, + { + name: "fail-binary-not-found", + cmd: "non-existent-command-12345", + args: []string{}, + wantStdout: "", + wantStderr: "", + wantExitCode: -1, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr, exitCode, err := Run(tt.cmd, tt.args...) + + if (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if exitCode != tt.wantExitCode { + t.Errorf("Run() exitCode = %v, want %v", exitCode, tt.wantExitCode) + } + + if tt.name == "success" && stdout != tt.wantStdout { + t.Errorf("Run() stdout = %q, want %q", stdout, tt.wantStdout) + } + + // For ls error, we just check that stderr is not empty since exact text varies + if tt.name == "fail-exit-code" && stderr == "" { + t.Errorf("Run() stderr = %q, want non-empty", stderr) + } + }) + } +} diff --git a/internal/resource/dir/dir.go b/internal/resource/dir/dir.go index 94220b7..83c017c 100644 --- a/internal/resource/dir/dir.go +++ b/internal/resource/dir/dir.go @@ -200,6 +200,6 @@ func Present(path string, opts ...opt.Option) resource.Resource { } func Absent(path string, opts ...opt.Option) resource.Resource { - opts = append(opts, opt.IsAbsent()) + opts = append(opts, opt.IsAbsent) return Present(path, opts...) } diff --git a/internal/resource/dir/dir_test.go b/internal/resource/dir/dir_test.go index c7ec92e..21af8e1 100644 --- a/internal/resource/dir/dir_test.go +++ b/internal/resource/dir/dir_test.go @@ -78,7 +78,7 @@ func TestPresentAbsentNonEmptyDirWithoutPruneFails(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent()) + Present(path, IsAbsent) if err := resource.Apply(); err == nil { t.Error("expected Apply to fail when removing non-empty directory without prune") } @@ -95,7 +95,7 @@ func TestPresentAbsentPruneDirectoryRecursive(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent(), WithPrune()) + Present(path, IsAbsent, WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } @@ -116,7 +116,7 @@ func TestAbsentPruneDirectoryRecursive(t *testing.T) { t.Fatal(err) } - Absent(path, WithPrune()) + Absent(path, WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } @@ -168,7 +168,7 @@ func TestPresentDirectoryWithSource(t *testing.T) { t.Fatal(err) } - Present(dst, WithSource(src), WithPrune()) + Present(dst, WithSource(src), WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } @@ -284,7 +284,7 @@ func TestSourceCopyWithPruneKeepsTemplatedFile(t *testing.T) { // Now apply with prune resource.ResetRepository() - Present(dst, WithSource(src), WithPrune()) + Present(dst, WithSource(src), WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } diff --git a/internal/resource/file/file.go b/internal/resource/file/file.go index 9d8a0d9..88b0bca 100644 --- a/internal/resource/file/file.go +++ b/internal/resource/file/file.go @@ -231,6 +231,6 @@ func Present(path string, opts ...opt.Option) resource.Resource { } func Absent(path string, opts ...opt.Option) resource.Resource { - opts = append(opts, opt.IsAbsent()) + opts = append(opts, opt.IsAbsent) return Present(path, opts...) } diff --git a/internal/resource/file/file_test.go b/internal/resource/file/file_test.go index b158657..3ae183c 100644 --- a/internal/resource/file/file_test.go +++ b/internal/resource/file/file_test.go @@ -194,7 +194,7 @@ func TestPresentAbsent(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent()) + Present(path, IsAbsent) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } diff --git a/internal/resource/link/link.go b/internal/resource/link/link.go index a5c5122..b40ce65 100644 --- a/internal/resource/link/link.go +++ b/internal/resource/link/link.go @@ -95,7 +95,7 @@ func Present(path string, opts ...opt.Option) resource.Resource { } func Absent(path string, opts ...opt.Option) resource.Resource { - opts = append(opts, opt.IsAbsent()) + opts = append(opts, opt.IsAbsent) return Present(path, opts...) } diff --git a/internal/resource/link/link_test.go b/internal/resource/link/link_test.go index 862d5f7..170fbdd 100644 --- a/internal/resource/link/link_test.go +++ b/internal/resource/link/link_test.go @@ -183,7 +183,7 @@ func TestPresentAbsentSymlink(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent()) + Present(path, IsAbsent) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } diff --git a/internal/resource/pkg/dnf.go b/internal/resource/pkg/dnf.go new file mode 100644 index 0000000..ad03da2 --- /dev/null +++ b/internal/resource/pkg/dnf.go @@ -0,0 +1,34 @@ +package pkg + +import ( + "fmt" + "log" + + "codeberg.org/snonux/gonf/internal/exec" +) + +func applyDNF(name string, ensure Ensure) error { + var args []string + + switch ensure { + case PkgPresent: + args = []string{"install", "-y", name} + case PkgAbsent: + args = []string{"remove", "-y", name} + case PkgLatest: + args = []string{"install", "-y", name} + default: + log.Fatalf("unsupported ensure state: %v", ensure) + } + + stdout, stderr, exitCode, err := exec.Run("dnf", args...) + if err != nil { + return fmt.Errorf("failed to execute dnf: %w", err) + } + + if exitCode != 0 { + return fmt.Errorf("dnf failed with exit code %d: %s\n%s", exitCode, stdout, stderr) + } + + return nil +} diff --git a/internal/resource/pkg/pkg.go b/internal/resource/pkg/pkg.go new file mode 100644 index 0000000..72f098c --- /dev/null +++ b/internal/resource/pkg/pkg.go @@ -0,0 +1,47 @@ +package pkg + +import ( + "errors" + "os" +) + +type Ensure int + +const ( + PkgPresent Ensure = iota + PkgAbsent + PkgLatest +) + +type applyFunc func(name string, ensure Ensure) error + +func Present(name string, ensure Ensure) error { + bin, err := detect() + if err != nil { + return err + } + + var applyFunc applyFunc + + switch bin { + case "dnf": + applyFunc = applyDNF + } + + return applyFunc(name, ensure) +} + +func detect() (string, error) { + switch { + case exists("/etc/fedora-release"): + fallthrough + case exists("/etc/rocky-release"): + return "dnf", nil + } + return "", errors.New("unable to detect package manager!") +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} -- cgit v1.2.3