Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ Short version: implement the [`Extension` interface](extensions/extension.go), d
- **Logging** via [google/deck](https://github.com/google/deck) -- syslog on Linux, Event Log on Windows
- **Builds** via [GoReleaser](https://goreleaser.com/) -- see [.goreleaser.yml](.goreleaser.yml)

## Testing Helpers (`internal/testutil`)

The [`internal/testutil`](internal/testutil/) package provides shared test infrastructure. Extensions should use these instead of rolling their own mocks.

| Helper | Purpose | Example |
|---|---|---|
| `MapFS` | In-memory `extensions.FS` for testing Check/Apply without root or real filesystem | `mfs := testutil.NewMapFS(); mfs.Set("/etc/conf", data, 0644)` |
| `AssertConverges(t, ext)` | Verifies the Check/Apply/Check contract: not-in-sync, Apply succeeds, in-sync | `testutil.AssertConverges(t, myFile)` |
| `AssertInSync(t, ext)` | Verifies Check reports in-sync | `testutil.AssertInSync(t, myFile)` |
| `AssertDrifted(t, ext)` | Verifies Check reports drift with changes | `testutil.AssertDrifted(t, myFile)` |
| `MockCmd` | Records `exec.Command` calls and returns scripted output | `mock := testutil.NewMockCmd(); mock.SetOutput("modprobe", "", nil)` |
| `MockPackageManager` | In-memory `pkg.PackageManager` for package extension tests | `mgr := testutil.NewMockPackageManager("mock")` |

Extensions that do file I/O should accept an `FS extensions.FS` field in their Opts struct. When nil, `extensions.RealFS(nil)` falls back to the real OS filesystem. In tests, inject a `MapFS` to verify behavior without touching disk.

## PR Checklist

- [ ] `go test ./... -race` passes
Expand Down
7 changes: 7 additions & 0 deletions extensions/cron/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package cron

import (
"fmt"

"github.com/TsekNet/converge/extensions"
)

// Cron ensures a cron job or Windows scheduled task exists
Expand All @@ -15,6 +17,7 @@ type Cron struct {
User string // user to run as (Linux/macOS: crontab owner, Windows: SYSTEM or username)
State string // "present" or "absent"
Critical bool
FS extensions.FS // nil uses the real OS filesystem
}

// Opts holds configurable fields for a Cron resource.
Expand All @@ -24,6 +27,7 @@ type Opts struct {
User string // user to run as (Linux/macOS: crontab owner, Windows: SYSTEM or username)
State string // "present" or "absent"
Critical bool
FS extensions.FS // inject a mock for testing
}

// New creates a Cron resource. State defaults to "present" when the Opts
Expand All @@ -40,9 +44,12 @@ func New(name string, opts Opts) *Cron {
User: opts.User,
State: state,
Critical: opts.Critical,
FS: opts.FS,
}
}

func (c *Cron) fsys() extensions.FS { return extensions.RealFS(c.FS) }

func (c *Cron) ID() string { return fmt.Sprintf("cron:%s", c.Name) }
func (c *Cron) String() string { return fmt.Sprintf("Cron %s", c.Name) }
func (c *Cron) IsCritical() bool { return c.Critical }
13 changes: 7 additions & 6 deletions extensions/cron/cron_crontab.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ package cron
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"io/fs"
"path/filepath"
"strings"

Expand Down Expand Up @@ -34,8 +35,8 @@ func (c *Cron) Check(_ context.Context) (*extensions.State, error) {
path := c.cronFilePath()
wantLine := c.cronLine()

data, err := os.ReadFile(path)
if os.IsNotExist(err) {
data, err := c.fsys().ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
if c.State == "absent" {
return &extensions.State{InSync: true}, nil
}
Expand Down Expand Up @@ -81,18 +82,18 @@ func (c *Cron) Apply(_ context.Context) (*extensions.Result, error) {
path := c.cronFilePath()

if c.State == "absent" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
if err := c.fsys().Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("remove %s: %w", path, err)
}
return &extensions.Result{Changed: true, Status: extensions.StatusChanged, Message: "removed"}, nil
}

if err := os.MkdirAll(cronDir, 0755); err != nil {
if err := c.fsys().MkdirAll(cronDir, 0755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", cronDir, err)
}

content := c.cronLine() + "\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
if err := c.fsys().WriteFile(path, []byte(content), 0644); err != nil {
return nil, fmt.Errorf("write %s: %w", path, err)
}

Expand Down
142 changes: 142 additions & 0 deletions extensions/cron/cron_mapfs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//go:build linux || darwin

package cron

import (
"context"
"testing"

"github.com/TsekNet/converge/internal/testutil"
)

func TestCron_MapFS_CheckMissing(t *testing.T) {
t.Parallel()
ctx := context.Background()

mfs := testutil.NewMapFS()
c := New("backup", Opts{
Schedule: "0 2 * * *",
Command: "/usr/bin/backup.sh",
FS: mfs,
})

state, err := c.Check(ctx)
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if state.InSync {
t.Error("Check() should report drift when cron file is missing")
}
if len(state.Changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(state.Changes))
}
if state.Changes[0].Action != "add" {
t.Errorf("Changes[0].Action = %q, want %q", state.Changes[0].Action, "add")
}
}

func TestCron_MapFS_CheckInSync(t *testing.T) {
t.Parallel()
ctx := context.Background()

mfs := testutil.NewMapFS()
c := New("backup", Opts{
Schedule: "0 2 * * *",
Command: "/usr/bin/backup.sh",
FS: mfs,
})

// Seed the expected content.
mfs.Set(c.cronFilePath(), []byte(c.cronLine()+"\n"), 0644)

state, err := c.Check(ctx)
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if !state.InSync {
t.Errorf("Check() should report in-sync, got changes: %+v", state.Changes)
}
}

func TestCron_MapFS_Converges(t *testing.T) {
t.Parallel()

mfs := testutil.NewMapFS()
c := New("backup", Opts{
Schedule: "0 2 * * *",
Command: "/usr/bin/backup.sh",
FS: mfs,
})

testutil.AssertConverges(t, c)

data, ok := mfs.Get(c.cronFilePath())
if !ok {
t.Fatal("cron file should exist in MapFS after Apply")
}
want := c.cronLine() + "\n"
if string(data) != want {
t.Errorf("content = %q, want %q", data, want)
}
}

func TestCron_MapFS_AbsentConverges(t *testing.T) {
t.Parallel()

mfs := testutil.NewMapFS()
c := New("cleanup", Opts{
Schedule: "30 3 * * 0",
Command: "/opt/cleanup.sh",
State: "absent",
FS: mfs,
})

// Seed an existing cron file that should be removed.
mfs.Set(c.cronFilePath(), []byte(c.cronLine()+"\n"), 0644)

testutil.AssertConverges(t, c)

if mfs.Has(c.cronFilePath()) {
t.Error("cron file should be removed after Apply with state=absent")
}
}

func TestCron_MapFS_AbsentAlreadyGone(t *testing.T) {
t.Parallel()

mfs := testutil.NewMapFS()
c := New("cleanup", Opts{
Schedule: "30 3 * * 0",
Command: "/opt/cleanup.sh",
State: "absent",
FS: mfs,
})

testutil.AssertInSync(t, c)
}

func TestCron_MapFS_ContentDrift(t *testing.T) {
t.Parallel()
ctx := context.Background()

mfs := testutil.NewMapFS()
c := New("backup", Opts{
Schedule: "0 2 * * *",
Command: "/usr/bin/backup.sh",
FS: mfs,
})

// Seed with wrong content.
mfs.Set(c.cronFilePath(), []byte("0 3 * * * root /usr/bin/old.sh # converge:backup\n"), 0644)

state, err := c.Check(ctx)
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if state.InSync {
t.Error("Check() should detect content drift")
}
if len(state.Changes) == 0 {
t.Error("expected at least one change")
}
}
6 changes: 5 additions & 1 deletion extensions/hostname/hostname.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,22 @@ import (
type Hostname struct {
Name string
Critical bool
FS extensions.FS // nil uses the real OS filesystem
}

// Opts holds configurable fields for a Hostname resource.
type Opts struct {
Critical bool
FS extensions.FS // inject a mock for testing
}

// New creates a Hostname resource.
func New(name string, opts Opts) *Hostname {
return &Hostname{Name: name, Critical: opts.Critical}
return &Hostname{Name: name, Critical: opts.Critical, FS: opts.FS}
}

func (h *Hostname) fsys() extensions.FS { return extensions.RealFS(h.FS) }

func (h *Hostname) ID() string { return "hostname:" + h.Name }
func (h *Hostname) String() string { return "Hostname " + h.Name }
func (h *Hostname) IsCritical() bool { return h.Critical }
Expand Down
3 changes: 1 addition & 2 deletions extensions/hostname/hostname_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package hostname
import (
"context"
"fmt"
"os"

"golang.org/x/sys/unix"

Expand All @@ -19,7 +18,7 @@ func (h *Hostname) Apply(_ context.Context) (*extensions.Result, error) {
return nil, fmt.Errorf("sethostname(%s): %w", h.Name, err)
}

if err := os.WriteFile("/etc/hostname", []byte(h.Name+"\n"), 0644); err != nil {
if err := h.fsys().WriteFile("/etc/hostname", []byte(h.Name+"\n"), 0644); err != nil {
return nil, fmt.Errorf("write /etc/hostname: %w", err)
}

Expand Down
18 changes: 12 additions & 6 deletions extensions/pkg/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package pkg

import (
"context"
"errors"
"fmt"
"os"
"io/fs"
"path/filepath"
"strings"

Expand All @@ -22,6 +23,7 @@ type Repository struct {
State string // "present" or "absent"
ManagerName string // "apt", "dnf", or "yum"
Critical bool
FS extensions.FS // nil uses the real OS filesystem
}

// RepositoryOpts holds configurable fields for a Repository resource.
Expand All @@ -34,6 +36,7 @@ type RepositoryOpts struct {
State string // "present" or "absent"
ManagerName string // "apt", "dnf", or "yum"
Critical bool
FS extensions.FS // inject a mock for testing
}

// NewRepository creates a Repository resource. State defaults to "present"
Expand All @@ -54,13 +57,16 @@ func NewRepository(name string, opts RepositoryOpts) *Repository {
State: state,
ManagerName: opts.ManagerName,
Critical: opts.Critical,
FS: opts.FS,
}
}

func (r *Repository) ID() string { return fmt.Sprintf("repository:%s", r.Name) }
func (r *Repository) String() string { return fmt.Sprintf("Repository %s (%s)", r.Name, r.ManagerName) }
func (r *Repository) IsCritical() bool { return r.Critical }

func (r *Repository) fsys() extensions.FS { return extensions.RealFS(r.FS) }

func (r *Repository) repoFilePath() string {
switch r.ManagerName {
case "apt":
Expand Down Expand Up @@ -106,8 +112,8 @@ func (r *Repository) Check(_ context.Context) (*extensions.State, error) {
return nil, fmt.Errorf("unsupported package manager for repositories: %q", r.ManagerName)
}

data, err := os.ReadFile(path)
if os.IsNotExist(err) {
data, err := r.fsys().ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
if r.State == "absent" {
return &extensions.State{InSync: true}, nil
}
Expand Down Expand Up @@ -152,18 +158,18 @@ func (r *Repository) Apply(_ context.Context) (*extensions.Result, error) {
}

if r.State == "absent" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
if err := r.fsys().Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("remove %s: %w", path, err)
}
return &extensions.Result{Changed: true, Status: extensions.StatusChanged, Message: "removed"}, nil
}

dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := r.fsys().MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
}

if err := os.WriteFile(path, []byte(r.repoContent()), 0644); err != nil {
if err := r.fsys().WriteFile(path, []byte(r.repoContent()), 0644); err != nil {
return nil, fmt.Errorf("write %s: %w", path, err)
}

Expand Down
24 changes: 24 additions & 0 deletions extensions/pkg/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/TsekNet/converge/internal/testutil"
)

func TestRepository_ID(t *testing.T) {
Expand Down Expand Up @@ -225,3 +227,25 @@ func TestRepository_UnsupportedManager(t *testing.T) {
t.Error("Apply() should fail for unsupported manager")
}
}

func TestRepository_MapFS_AptCheckAndApply(t *testing.T) {
mfs := testutil.NewMapFS()

r := NewRepository("chrome", RepositoryOpts{
URI: "https://dl.google.com/linux/chrome/deb/",
Distribution: "stable",
Components: "main",
ManagerName: "apt",
Enabled: true,
FS: mfs,
})
testutil.AssertConverges(t, r)

data, ok := mfs.Get("/etc/apt/sources.list.d/chrome.list")
if !ok {
t.Fatal("repo file should exist in MapFS")
}
if !strings.Contains(string(data), "deb https://dl.google.com/linux/chrome/deb/ stable main") {
t.Errorf("content = %q", data)
}
}
Loading
Loading