Repository navigation
Releases: snonux/gonf
Release list
v0.12.1
Release v0.12.1
v0.12.1
Overview
This is a small documentation and terminology cleanup release. Following the earlier rename of the inventory concept from Fleet to Cluster (where a Fleet is now a collection of Clusters), a few stragglers in comments, help strings, and docs still referred to clusters as "fleets." This release sweeps those up so the terminology is consistent across the codebase, and bumps the reported version.
Improvements
-
Consistent "cluster" terminology. The
ClusterInfotype,ClusterHosts()error messages,WithClusterdocumentation, and the helpers reference now uniformly say "cluster" instead of "fleet." If you read error output or API comments while writing cluster-driven recipes, the language now matches the names you actually use in your inventory declarations. -
Simpler
WithClusterdocs. The comment onRegisterMethods(…, WithCluster(name))no longer tells you to callfleet.Register(the old fleet API). It simply notes that the cluster must already be registered, reflecting how registration actually works now.
Migration
No behavior changes and no action required. This release only touches comments, one user-facing error string (which now correctly says "cluster"), and the version constant reported by gonf -version.
Changelog Details
api/cluster.go:ClusterInfocomment now reads "listing row for registered clusters."api/cluster_hosts.go:ClusterHosts()and the task-cluster stack comments/error message use "cluster" wording.api/methods.go:WithClusterdoc comment updated to cluster terminology and registration guidance.docs/helpers.md: the "Fleet hosts and per-host values" section is now "Cluster hosts and per-host values."internal/version.go: version bumped from0.12.0to0.12.1.
v0.12.0
Release v0.12.0
Release Notes
Overview
This release introduces a Cluster abstraction layered beneath the existing Fleet concept, giving you a cleaner, more expressive way to organize your host inventory. Where Fleet previously described a flat list of hosts, it is now a list of clusters — named groups of hosts that can be composed into larger fleets. This makes it straightforward to model real-world topologies (e.g., "edge nodes" and "core nodes" as separate clusters, both belonging to a single "homelab" fleet) while pushing to the union of all hosts.
Breaking Changes
Fleet semantics changed — this is the most significant change in the release.
Fleet(name, hosts...)→Fleet(name, clusters...):Fleetnow acceptsClusterRefhandles, notHostRefhandles. A fleet is a named set of clusters, and hosts may overlap across member clusters (they are deduplicated automatically on push).Clusteris the new unit of host grouping: The formerFleetrole (a named set of hosts with a parallelism setting) is nowCluster(name, hosts...). All the host-level options (WithSSHUser,WithSSHPort,WithPrivilege, etc.) and theParallel(n)concurrency control now live onCluster.- Renamed task-scope helpers:
FleetHosts()→ClusterHosts()WithTaskFleet(name)→WithTaskCluster(name)WithFleet(name)(onRegisterMethods) →WithCluster(name)LookupFleet/MustFleetnow resolve toFleetRef(a list of clusters); useLookupCluster/MustClusterfor the host-grouping unit.Fleets()listing now reportsClusters []stringand a flattenedHosts []string.
Migration steps:
- Replace every
Fleet("name", hostA, hostB, ...)call withCluster("name", hostA, hostB, ...). - If you need a group-of-groups, introduce a
Fleet("name", cluster1, cluster2, ...)that references your new clusters. - Rename task-scope calls:
FleetHosts()→ClusterHosts(),WithTaskFleet→WithTaskCluster,WithFleet→WithCluster. - Replace
PushFleet("name", tasks...)withPushCluster("name", tasks...)when pushing to a single host group; usePushFleetwhen pushing across multiple clusters.
If you were using
Fleetpurely as "a set of hosts" with no composition, the rename toClusteris the only change you need — drop the wrapperFleetentirely and push to the cluster directly.
Features
Clusterinventory type — A first-class, named group of hosts with its own parallelism knob (Cluster("edge", h1, h2, h3).Parallel(4)). This is where SSH credentials, privilege mode, and per-host values belong. It gives you a natural place to draw the line between "how to talk to these machines" (cluster) and "which machines are in scope for this recipe" (fleet).Fleetas a list of clusters — A fleet now composes clusters, enabling overlapping host membership. For example, anedgecluster and acorecluster can both include a shared bastion host;PushFleetpushes to each unique host exactly once. This mirrors how infrastructure is actually organized (role-based groups that share some machines).FleetRef.ClusterNames()— Inspect which clusters make up a fleet, useful for debugging and reporting.
Improvements
- Cleaner conceptual separation — Previously "fleet" conflated two ideas: how to connect (SSH user, port, privilege) and which hosts to target. Splitting these into
Cluster(connection + host set) andFleet(composition of clusters) makes recipes easier to read and reuse across environments. - Deduplication on fleet push — Overlapping hosts across clusters are transparently deduplicated, so a shared bastion in two clusters receives exactly one push rather than duplicate or conflicting ones.
- Documentation updated —
helpers.mdandplan.mdnow reference the newCluster/ClusterHosts/WithClusternames, and the gap-analysis doc is corrected to reflectPushCluster.
Bug Fixes
- Test suite corrected for the new API — All fleet tests were renamed and updated (
TestFleetOfClusters,TestClusterDuplicateHostNames,TestPushClusterParallel, etc.) to validate the new cluster/fleet semantics, including a new test confirming that overlapping hosts across clusters are deduplicated correctly.
Summary
If you manage more than a handful of hosts, this release gives you the vocabulary to describe your infrastructure the way you actually think about it: groups of similar machines (clusters) composed into broader scopes (fleets). The cost is a one-time rename, but the payoff is recipes that scale beyond a single flat host list.
v0.11.2
Release v0.11.2
Highlights
This patch release resolves a remote provisioning issue that affected FreeBSD users. When gonf checked a remote host's version before pushing a plan, the probe could fail on systems using tcsh as a login shell — causing unnecessary binary resyncs or, in some cases, push failures. The fix makes the version probe shell-agnostic.
Bug Fixes
Fixed remote plan-version probe on FreeBSD with tcsh login shells
What: When pushing a plan to a remote host, gonf probes the installed gonf binary for its plan schema version to decide whether it needs to sync an updated binary first. The probe command previously appended a POSIX shell idiom (2>/dev/null || true) that tcsh — commonly used as the login shell on FreeBSD — misinterprets, yielding empty output even when gonf is present and working correctly.
Why it matters:
- FreeBSD users no longer experience spurious "gonf not found" or "unsupported plan schema" errors during remote pushes.
- Unnecessary binary resyncs are eliminated, speeding up fleet operations and reducing bandwidth use on constrained hosts.
- The fix leverages
sshCapture's existing behavior of treating a non-zero remote exit as empty output, so missing-binary detection still works correctly without the shell idiom.
v0.11.1
Release v0.11.1
Overview
This is a small patch release focused on improving the robustness of gonf's remote binary synchronization. When gonf automatically syncs a newer binary to remote hosts during a push operation, it now correctly handles SSH port configurations specified via extra SSH options, preventing failures on non-standard port setups.
Bug Fixes
Fixed SCP port flag when pushing to remotes using ssh -p
What was fixed: When gonf needed to sync a newer binary to a remote host (which happens automatically when the remote's plan schema version doesn't support the current push protocol), it used scp under the hood. If you had configured a custom SSH port via ExtraSSH options (e.g., []string{"-p", "2222"}), gonf was passing that flag through verbatim to scp. However, scp interprets -p as "preserve modification times," not as a port specifier — the correct flag is -P.
This meant binary sync could fail silently or behave unexpectedly when your SSH connection used a non-standard port specified through extra options rather than the dedicated Port field.
Why it matters: Users who route SSH traffic through jump hosts, firewalls, or load balancers on non-default ports will now have reliable automatic binary synchronization. The fix translates ssh -p PORT (and scp -P PORT) found in ExtraSSH into the proper scp -P PORT flag, while leaving other SSH options (like -o StrictHostKeyChecking=yes) untouched.
No migration required. This fix is transparent — existing configurations that worked before continue to work, and configurations that relied on ExtraSSH for port specification now work correctly for the binary-sync path.
Technical Details
- Extracted SCP argument construction into a dedicated
scpArgvhelper function for clarity and testability - Added unit tests verifying the port flag translation logic
- The fix handles both
-p(SSH convention) and-P(SCP convention) inExtraSSH, taking the first valid port found and merging it with thePortfield (thePortfield takes precedence if both are set)
v0.11.0
Release v0.11.0
Release Notes
Overview
This release makes gonf push self-contained: when a target host runs an outdated (or missing) gonf binary, the controller now detects it and automatically syncs a current one before applying your configuration. No more manual binary juggling across your fleet, and no more push failures caused by plan-schema version drift between controller and targets.
Features
Automatic remote gonf binary sync
Before the first apply chunk of a push, gonf now probes the target's plan-schema version. If the remote binary is missing or too old to understand the plan being sent, gonf:
- Detects the host's OS and architecture (via
uname), or uses explicit overrides. - Cross-compiles a
gonfbinary for that host on the fly (cached per GOOS/GOARCH within the push, so mixed-arch fleets don't rebuild repeatedly). - Transfers and installs it to the host using
scpandinstall, honoring the host's privilege mode (sudo,doas, or plain root — root logins install without elevation). - Verifies the installed binary via its explicit path so a stale
PATHentry can't shadow it, then re-runs the remaining apply commands against that path.
This means a single controller binary can roll out config to a heterogeneous fleet even when the targets are on older releases — the tool upgrades itself where needed.
New host options for controlling sync
Three new options on Host/Fleet let you steer the sync behavior:
WithGOOS/WithGOARCH— pin the cross-compile target instead of probing the host (useful for exotic or cross-compiled targets).WithGonfPath— choose a custom remote install path (default/usr/local/bin/gonf).
-plan-version flag
gonf -plan-version now prints the plan-schema integer the binary can emit and apply — distinct from -version, which prints the release string. This is what the sync logic uses to compare controller and remote capabilities.
Improvements
- Privilege-aware apply commands now carry an explicit binary path, so elevated and unprivileged chunks both target the freshly installed binary consistently.
- Cleaner remote pre-flight — privilege misconfiguration (e.g.
-privilege=nonewith an elevated chunk) still fails fast before any SSH traffic, now also before any binary sync occurs. - Testability seams — the new sync path is fully testable without real SSH; existing tests were updated to stub the version probe, keeping the suite deterministic.
Migration Notes
No breaking changes. If your targets already run a current gonf binary, the version probe succeeds and the sync path is skipped entirely — behavior is unchanged.
One note for hosts where the SSH login is not root and Privileged() is not set: the sync install writes to /usr/local/bin/gonf, which requires write access there. On standard setups you'll want Privileged() (with sudo or doas) or a root login, which the install respects automatically.
Docs
The plan documentation now covers the remote-binary-sync flow end to end, including how the probe works and how to override the install path and compile target.
v0.10.0
Release v0.10.0
Release Notes
Overview
This release focuses on making fleet-based configuration simpler and more idiomatic. If your recipes manage multiple hosts, you no longer need to thread host lists and parallel value maps through your code manually. You can now attach arbitrary typed values directly to hosts in your inventory, associate a fleet with your task methods once, and iterate over that fleet's hosts cleanly from within your recipe bodies.
Features
Per-host recipe values
You can now store arbitrary typed values on individual hosts at inventory time:
Host("web", WithSSHHost("web.example"),
WithValue("cron", [2]string{"6", "7"}),
)Values are retrieved inside task bodies with a type-safe accessor:
window := MustHostValue[[2]string](host, "cron")Why this is useful: Previously, you'd maintain a separate map[string]T alongside your host registry and use MustMapValue to look entries up, risking drift between the fleet membership and the value map. Now the value lives on the host record itself, so a host is registered or isn't — there's no second source of truth to keep in sync. Missing keys and type mismatches fail fast with clear error messages, the same contract as MustHost / MustFleet.
FleetHosts() — iterate the current task's fleet from inside a task body
func (MyTasks) Cron() {
for _, host := range FleetHosts() {
w := MustHostValue[[2]string](host, "cron")
WhenHostname(host, func() { /* … */ })
}
}FleetHosts() returns the list of inventory names for the fleet associated with the currently running task. It fails fast if called outside a fleet-scoped task.
Why this is useful: Previously you'd need to pass the fleet name or host list around explicitly to know which hosts the current task operates on. Now the association is implicit — set once at registration, available anywhere in the body.
WithFleet on RegisterMethods and WithTaskFleet on Task
RegisterMethods(MyTasks{}, WithPrefix("edge_"), WithFleet("edge"))associates a fleet with every method on a struct in one declaration.Task("name", "desc", fn, WithTaskFleet("edge"))does the same for a single task.
Why this is useful: For a struct with many methods that all target the same fleet, you declare the association once rather than repeating it per method. Nested task execution is handled correctly — an inner Aggregate calling a child task pushes its own fleet frame, so each task body always sees its own fleet.
Improvements
List(...) promoted to the canonical DSL alias for []string
List("a", "b", "c") is now documented and recommended everywhere you'd otherwise write []string{"a", "b", "c"} — in multi-path resources, Command args, WhenHostname hosts, EachKV pairs, and similar.
Why this is useful: A single, discoverable spelling for "a list of strings" across the DSL reduces cognitive load and makes recipes more uniform. Docs and examples have been updated to prefer List(...) throughout.
MustMapValue replaced by MustHostValue
The old generic MustMapValue[K, V] helper has been removed in favor of MustHostValue[T], which reads from the host registry directly.
Why this is useful: The old helper required you to carry a map around and explain what it was for. The new one is self-contained — you name the host and the key, and it fetches from the inventory.
Documentation
- New "Fleet hosts and per-host values" section in
helpers.mdwith a complete worked example. WhenHostnamedocs and examples updated to showList(...)instead of[]string{...}.plan.mdandtasks.mdtables updated to reflect theList(...)spelling.
Migration Notes
| Old | New |
|---|---|
MustMapValue(m, key, what) |
MustHostValue[T](host, key) |
[]string{"a", "b"} in DSL recipes |
List("a", "b") |
| Manual fleet-name threading in task bodies | WithFleet / WithTaskFleet + FleetHosts() |
If you have existing recipes using MustMapValue, replace the parallel map with WithValue / SetValue on each host and switch the lookups to MustHostValue. The List(...) change is cosmetic — []string{...} still works, but List(...) is now preferred in docs and examples.
v0.9.2
Release v0.9.2
Release Notes
This release adds fleet-level introspection to the gonf API, letting recipes enumerate the hosts belonging to a fleet and safely look up per-host values during configuration authoring. Together with the new MustMapValue helper, these additions make it straightforward to drive per-host settings (such as staggered cron schedules) from a fleet definition.
Features
FleetRef.HostNames()
A new method on FleetRef returns the inventory names of the hosts in a fleet, in registration order:
names := api.MustFleet("web").HostNames()Why this matters: Previously, recipes had no way to programmatically ask a fleet which hosts it contained, which made it awkward to write loops that apply host-specific configuration. With HostNames(), you can iterate over a fleet and emit per-host resources (files, cron entries, services) from a single recipe. Like other Must* accessors, an unregistered fleet handle fails fast via logger.Fatal, surfalling missing-inventory mistakes at registration time rather than at apply time.
MustMapValue generic map lookup
A new generic helper in api:
window := api.MustMapValue(cronWindows, hostName, "cron window")It returns the value for a given key, or aborts the recipe via logger.Fatal when the key is missing.
Why this matters: When you drive per-host settings from a map (e.g. a per-host cron schedule keyed by host name), a fleet host added without a corresponding map entry would otherwise silently fall back to a default and apply the wrong configuration. MustMapValue follows the same fail-fast contract as MustHost and MustFleet, so a host missing from the map aborts the recipe before apply — catching inventory/config drift loudly and early.
Improvements
Hosts() now round-trips Port and Privilege
The Hosts() registry query is now exercised (and verified) to carry each host's SSH port and privilege mode (sudo/doas) through to the returned HostInfo values.
Why this matters: Callers that build host lists or reports from Hosts() can now see the full connection and privilege picture per host rather than a partial one. The release also pins down the intended behavior that a host without an explicit WithSSHPort reports Port as 0 — an important distinction, since SSH omits -p for port 0 and falls back to ~/.ssh/config. Surfacing the zero value lets callers detect a missing explicit port instead of silently inheriting a local SSH config default.
Testing
- Expanded fleet registry tests to cover port and privilege round-tripping through
Hosts(), registration-order preservation inHostNames(), and theMustMapValuehelper.
Note: This release adds new API surface only; no existing behavior changes. There are no breaking changes or migration steps required.
v0.9.1
Release v0.9.1
Release Notes
This release adds a quality-of-life improvement to the WhenHostname body-level recipe, letting you gate a task's body on several hostnames with a single call instead of repeating the same block per host. It's a small, focused update: one API extension, a version bump, and corresponding doc updates.
Features
WhenHostname now accepts a []string
WhenHostname was previously limited to a single hostname substring:
WhenHostname("pi2", func() { Package("ksh") })To gate the same body on multiple hosts, you had to repeat yourself:
WhenHostname("pi2", func() { Package("ksh") })
WhenHostname("pi3", func() { Package("ksh") })Now you can pass a slice and get one fragment per entry automatically — identical per-host bodies stay DRY:
WhenHostname([]string{"pi2", "pi3"}, func() { Package("ksh") })Why this is useful: in a single recorded plan you often want the same set of resources (packages, cron jobs, files) applied to a group of hosts that share a naming convention. Each entry still expands into its own when_begin/when_end recipe scope with its own hostname_contains predicate, so the recorded plan carries the same structure as if you had looped manually — but the source is shorter and less error-prone when you add or remove a host.
The single-string form is unchanged and still works exactly as before, so this is a purely additive change.
Improvements
- Documentation updated.
docs/plan.mdanddocs/tasks.mdnow reflect the slice form ofWhenHostnameso the recipe table and the task-recipe table stay accurate. - Version bumped to 0.9.1 (
internal/version.go), reported bygonf -version.
Compatibility
No breaking changes. WhenHostname is now generic (WhenHostname[T Path]) but accepts both string and []string; existing single-string call sites compile and behave identically.
Testing
A new plan-recording test (TestRecordPlanWhenHostnameSlice) verifies that a slice expands into the expected sequence of when_begin → resource → when_end ops, with each fragment carrying the correct hostname_contains predicate.
v0.9.0
Release v0.9.0
Release Notes
gonf v0.9.0 adds a new SystemdTimer resource that lets you declare a scheduled job in a few lines of Go and have gonf generate, install, and maintain both systemd unit files for you. The new resource is fully integrated into the plan engine, so it works identically for local runs and remote pushes to a fleet of hosts.
Features
Declarative systemd timers
The headline addition is the SystemdTimer resource, alongside its counterpart NoSystemdTimer. Previously, scheduling a recurring job with systemd meant manually juggling several moving parts: writing a .timer file, writing a companion oneshot .service file, running daemon-reload, and enabling the timer. With SystemdTimer, you describe the job once and gonf handles all of it:
SystemdTimer("backup",
WithCommand("/usr/local/bin/backup.sh"),
WithOnCalendar("*-*-* 03:00:00"),
WithOnBootSec("10min"),
WithPersistent,
WithDescription("Nightly backup"),
WithAfter("network-online.target"),
)This is useful because it keeps scheduled jobs fully declarative and idempotent. gonf writes the unit files, runs daemon-reload only when they actually change, and enables and starts the timer. When a timer is absent, NoSystemdTimer stops, disables, and cleanly removes both unit files.
Key capabilities:
- Full unit generation — gonf generates both the
.timerand the oneshot.service, so you never hand-edit unit files. - Rich scheduling —
WithOnCalendar(required), optionalWithOnBootSecdelay, andWithPersistentto catch up on missed runs while the machine was off. - Service dependencies —
WithAfterandWithWantslet you order the one-shot run relative to other units (for example, waiting for the network to be online). - Clear unit descriptions —
WithDescriptionandWithServiceDescriptionset the[Unit] Descriptionfor the timer and the service separately, makingsystemctloutput easier to read. - System or user units —
WithUsertargets~/.config/systemd/user/for user-level timers instead of/etc/systemd/system/. - Optional name suffix — you can name the resource with or without the
.timer/.servicesuffix; gonf normalizes it. - Required-field validation — a present timer must declare both a command and a calendar expression, and errors surface clearly and early.
Plan-engine support for timers
The new timer is a first-class citizen in gonf's plan pipeline. Recording a run now emits a systemd_timer plan operation that carries the command, calendar, schedule options, descriptions, and dependencies, and applying that plan installs the units exactly as the local run would. This means scheduled jobs defined with SystemdTimer behave the same way whether applied directly on a machine or pushed over SSH to a remote fleet.
Improvements
Expanded option surface for timers
A set of new, reusable options has been added for timers: WithOnCalendar, WithOnBootSec, WithPersistent, WithDescription, WithServiceDescription, WithAfter, and WithWants. These follow the same composable options pattern used elsewhere in gonf, keeping the API consistent and giving you fine-grained control without boilerplate. Existing timer options (WithRestart, WithEnableOnly, WithUser) now also apply to the new resource.
Clearer guidance on which timer to use
The documentation now explains when to prefer SystemdTimer (gonf owns the unit content) versus the existing Timer resource (you install units from a source tree and only want to enable/start them). This helps you pick the right tool and avoid redundant or conflicting unit management.
Compatibility and Migration
- Plan schema version bumped to 7. The new
systemd_timeroperation is introduced under schema version 7. This release continues to apply plans recorded with versions 1–6, so existing recorded plans remain valid. Older gonf binaries, however, will refuse version 7 plans up front rather than failing at apply time, so remote targets running an older binary cannot silently misinterpret a new plan. - No breaking changes to existing APIs. All previously documented resources and options continue to work unchanged.
SystemdTimeris purely additive.
Example
A complete scheduled backup job, system or user scope:
Task("backup", "nightly backup", func() {
SystemdTimer("backup",
WithCommand("/usr/local/bin/backup.sh"),
WithOnCalendar("*-*-* 03:00:00"),
WithOnBootSec("10min"),
WithPersistent,
WithDescription("Nightly backup"),
WithServiceDescription("Run backup.sh once"),
WithAfter("network-online.target"),
WithWants("network-online.target"),
)
})v0.8.1
Release v0.8.1
Predictable option collection for registered structs
gonf now assembles struct-level task options in a single, documented order — no matter how those options are attached. The same struct declaration always produces the same effective settings, and obvious declaration mistakes are reported clearly at registration time instead of surfacing as confusing errors later.
Fixed
- Consistent collection order. Options supplied through a
StructTaskOptionsmethod (defined on the struct, or promoted from an embedded marker) could previously be combined at a different point in the sequence than options from embedded markers, contradicting the documented "markers first, thenOpts()" order. Because order matters when several sources set the same option, this could silently change a task's effective settings. Collection now always follows one sequence: embedded markers in declaration order, then theOpts()companion, with theStructTaskOptionsfallback consulted only when a struct declares no marker fields. A regression test locks this behavior in. - One well-defined path for method-supplied options. Whether a struct's
StructTaskOptionsmethod applies no longer depends on a "only if nothing else was collected" check, which could mix or drop option sources in edge cases. The method's role is now explicit: it is used exactly when the struct has no embedded marker fields, so its options are never silently lost or unexpectedly combined with other sources.
Improved
- Fail fast on malformed methods.
StructTaskOptionsmust be declared asfunc() TaskOptions— the same contract asOpts(). Wrong signatures now cause a clear, explicit panic at registration instead of an opaque error deep inside option collection, making typos and refactoring slips much easier to catch.
Behavior changes and migration notes
- Embed option markers as exported types. Unexported embedded markers are now deliberately skipped during collection. Their options are still honored through the promoted method, but only when the struct has no other marker fields — if a struct mixes exported and unexported markers, the unexported one's options are no longer applied. Exporting the embedded marker type resolves this.
- Review structs combining
Opts()withStructTaskOptions. The relative order of those two option sets changed. If such a struct relies on one set overriding the other, verify its effective settings after upgrading and move options between the two sources as needed.
No action is required for structs that already embed markers as exported types and do not mix Opts() with StructTaskOptions.