This guide walks through adding a new extension to Converge. Extensions are everything that touches the OS: package managers, init systems, file operations, etc.
Every extension implements:
type Extension interface {
ID() string
Check(ctx context.Context) (*State, error)
Apply(ctx context.Context) (*Result, error)
String() string
}Check()reads current state and compares to desired. No root needed.Apply()makes changes. Requires root.ID()returns a unique identifier likefile:/etc/motdorpackage:git.
Optionally implement CriticalResource to control whether failure halts the run:
type CriticalResource interface {
IsCritical() bool
}In daemon mode (converge serve), extensions can implement optional interfaces for drift detection:
// Watcher blocks on native OS events (inotify, kqueue, dbus, etc.)
// and sends events when the resource may have drifted.
type Watcher interface {
Watch(ctx context.Context, events chan<- Event) error
}
// Poller overrides the default poll interval for resources without
// native OS event support.
type Poller interface {
PollInterval() time.Duration
}When to implement Watcher: if your resource type has a native OS mechanism for change notification (file system events, D-Bus signals, registry change notifications). See extensions/file/watch_linux.go for a reference implementation using inotify.
When to implement Poller: if your resource type has no native events but needs a custom poll frequency. For example, packages poll every 5 minutes since package state rarely changes externally.
Extensions implementing neither fall back to the daemon's default poll interval (30 seconds).
| Field | Type | Description |
|---|---|---|
ResourceID |
string |
Unique identifier of the resource that triggered the event (e.g. file:/etc/motd) |
Kind |
EventKind |
How the event was generated (see constants below) |
Detail |
string |
Human-readable context, such as "inotify", "kqueue", or "RegNotifyChangeKeyValue" |
Time |
time.Time |
Timestamp when the event was created |
| Constant | Value | Description |
|---|---|---|
EventWatch |
0 |
OS-level watcher detected a change |
EventPoll |
1 |
Periodic poll detected drift |
EventRetry |
2 |
Scheduled retry after a previous failure |
EventCondition |
3 |
A condition gate became true, triggering initial convergence |
Create extensions/pkg/dnf.go:
package pkg
import (
"context"
"fmt"
"os/exec"
)
type dnfManager struct{}
func (d *dnfManager) Name() string { return "dnf" }
func (d *dnfManager) IsInstalled(ctx context.Context, name string) (bool, error) {
cmd := exec.CommandContext(ctx, "rpm", "-q", name)
err := cmd.Run()
return err == nil, nil
}
func (d *dnfManager) Install(ctx context.Context, name string) error {
cmd := exec.CommandContext(ctx, "dnf", "install", "-y", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("dnf install %s: %s: %w", name, out, err)
}
return nil
}
func (d *dnfManager) Remove(ctx context.Context, name string) error {
cmd := exec.CommandContext(ctx, "dnf", "remove", "-y", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("dnf remove %s: %s: %w", name, out, err)
}
return nil
}In extensions/pkg/pkg.go, add dnf to the detection logic:
func detectManager(name string) PackageManager {
switch name {
case "apt":
return &aptManager{}
case "dnf":
return &dnfManager{}
// ...
}
}Create extensions/pkg/dnf_test.go with table-driven tests:
func TestDnfManager_Name(t *testing.T) {
m := &dnfManager{}
if m.Name() != "dnf" {
t.Errorf("Name() = %q, want 'dnf'", m.Name())
}
}- Ensure
go test ./... -racepasses - Ensure
go vet ./...passes - One file + one test file + factory registration
- No changes to
internal/required
Some extensions have sub-interfaces for platform-specific implementations:
| Extension | Sub-Interface | Implementations |
|---|---|---|
pkg/ |
PackageManager |
apt, brew, choco, dnf, yum, zypper, apk, pacman, winget |
service/ |
Platform build tags | systemd (Linux), launchd (macOS), SCM (Windows) |
firewall/ |
Platform build tags | nftables/netlink (Linux, IPv4 only), pf/anchor (macOS), registry API (Windows) |
To add a new package manager or init system, implement the sub-interface and register it. The engine doesn't change.
Each extension lives in its own subdirectory under extensions/. The shared extension.go and state.go define the interfaces and types: don't modify these. Current extensions: file, exec, firewall, pkg, reboot, service, user, registry, secpol, auditpol, sysctl, plist.
Use Go build tags to split platform-specific code. There are no stubs: if a platform doesn't need an extension, the DSL simply doesn't expose it.
Each extension has a shared file (no build tag) with the struct, New(), ID(), String(), IsCritical(). Platform-specific Check() and Apply() go in build-tagged files (e.g., service_linux.go, service_windows.go).
Rules:
- The struct definition and
New()constructor stay in the shared file (no build tag) Check()andApply()go in build-tagged files (one per platform)- Helper functions used only by one platform go in that platform's file
- Windows extensions should use native Win32 APIs (via
golang.org/x/sys/windowsorwindows.NewLazySystemDLL), not shell out to executables
If your extension is platform-specific (like Registry or Sysctl), you also need to wire it into the DSL. Cross-platform methods go in dsl/run.go, platform-specific methods in dsl/run_<platform>.go, and factories in dsl/resources.go or dsl/resources_<platform>.go.
To add a platform-specific DSL method:
- Add the
Optsstruct todsl/dsl.go(no build tag -- it's just a data type) - Add the
r.MyResource()method to the appropriatedsl/run_<platform>.go - Add the
newMyResourceExtension()factory to the appropriatedsl/resources_<platform>.go - Import your extension package in the factory file
The compiler enforces correctness: a Linux blueprint can call r.Sysctl() but not r.Registry(). No runtime "skipped" messages, no stubs.
If a blueprint calls platform-specific DSL methods, the blueprint file itself needs a build tag:
//go:build windows
package cis
import "github.com/TsekNet/converge/dsl"
func WindowsCIS(r *dsl.Run) {
r.Registry(`HKLM\...`, dsl.RegistryOpts{...})
}func TestUser_Apply(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix-only test")
}
// test useradd logic
}- Keep extensions stateless -- all state comes from Check()
- Use
context.Contextfor cancellation and timeouts - Wrap errors with
fmt.Errorf("...: %w", err)for debugging - For Windows: prefer
golang.org/x/sys/windows/registry,golang.org/x/sys/windows/svc/mgr, andwindows.NewLazySystemDLLoverexec.Command - For Linux: prefer direct file I/O (
/proc/sys/,/etc/sysctl.d/) over shelling out - For macOS: prefer
howett.net/plistfor plist files over thedefaultscommand