Hosts and modules
Declare several machines in one spec, share configuration between them with modules, and split the spec into files.
A spec declares one or more hosts. Each host lists its modules: the packages, files, services and other resources it has. A module is ordinary Starlark, usually a function, so sharing configuration between machines is the same as calling a function twice.
Declare a host
hosts = [
host(
name = "desktop",
platform = "x86_64-linux",
system = fedora(packages = ["fish", "git"]),
users = [user("alex", uid = 1000, shell = "fish")],
modules = [pkg("ripgrep")],
),
]
| Argument | What it decides |
|---|---|
name | Which machine this is. It matches the system host name, lowercased, up to the first dot |
platform | The CPU and OS: x86_64-linux, aarch64-linux, aarch64-darwin, x86_64-darwin |
system | On Linux, the distro or bootc image. macOS hosts leave it out |
users | The accounts FormalSpec manages |
modules | Everything else |
The full list, including ssh, protect, release_age, builders and sync, is in the builtins reference.
When you run a command without --host, FormalSpec picks the host whose name matches this machine. A host with any other name is remote, and apply reaches it over ssh (Remote hosts).
Write a module
A module is a function that returns a list of resources:
def cli():
return [
pkg("ripgrep"),
pkg("fd", source = github("sharkdp/fd")),
pkg("jq"),
]
modules accepts a resource, a list of resources (nested lists are flattened), or a function. A function in modules is called with the host's facts, so one module can adapt to each host:
def cli(host):
tools = [pkg("ripgrep"), pkg("fd", source = github("sharkdp/fd"))]
if host.platform.is_darwin:
tools.append(pkg("aerospace", source = brew(cask = True)))
if host.distro == "fedora":
tools.append(pkg("dnf-plugins-core", source = dnf()))
return tools
hosts = [
host(name = "laptop", platform = "aarch64-darwin", modules = [cli]),
host(name = "desktop", platform = "x86_64-linux", system = fedora(), modules = [cli]),
]
Pass the function itself (cli) to get the facts. Calling it yourself (cli()) works too, for a module that takes no facts or takes your own arguments.
The facts struct has name, platform (name, os, arch, is_linux, is_darwin), distro and init. They come from the declaration, so evaluation never depends on which machine it runs on.
A plain if replaces Nix's mkIf, and a function argument replaces an option. There is no module system to learn: no option types, no priorities, no merging.
Build hosts from a function
Hosts that differ in a few details are one function:
load("//modules/cli.star", "cli")
load("//modules/desktop.star", "desktop")
def laptop(name, extra = []):
return host(
name = name,
platform = "x86_64-linux",
system = image(base = "ghcr.io/ublue-os/bazzite:stable"),
users = [user("alex", uid = 1000, shell = "fish")],
modules = [cli, desktop] + extra,
)
hosts = [
laptop("work"),
laptop("home", extra = [pkg("steam", source = flatpak("com.valvesoftware.Steam"), mode = "self_managed")]),
]
Split the spec into files
load imports names from another file. Paths start with //, meaning the directory that holds spec.star:
load("//modules/cli.star", "cli")
load("//modules/desktop.star", "desktop", "FONTS")
Relative paths without // are an error. A loaded file can load others the same way. Names starting with _ stay private to their file.
The usual layout:
spec.star entry point, sets hosts
modules/ one concern per file
files/ config files that file(src = ...) copies
patches/ .patch files referenced by packages
secrets/ .age files
secrets.star who each secret is encrypted to
fspec.lock generated by fspec update, committed
Paths in file(src = ...), patches and glob(...) are relative to the spec directory, from whichever file names them.
Load a module from another repository
A project can ship its own recipe, and a spec can load it straight from GitHub:
load("gh:FormalStudio/formalmusic@main//fspec.star", "formalmusic")
hosts = [host(name = "laptop", platform = "aarch64-darwin", modules = [formalmusic()])]
The part after @ picks the version:
| Form | Meaning |
|---|---|
gh:owner/repo//file.star | The default branch, moved by each fspec update |
gh:owner/repo@branch//file.star or @tag | That branch or tag, moved by each fspec update |
gh:owner/repo@<40-hex commit>//file.star | That commit, never moved |
fspec update resolves the ref to a commit, fetches the file and every // file it loads from the same repository, and records the commit and each file's hash in fspec.lock:
[loads."gh:FormalStudio/formalmusic@main"]
rev = "0123456789abcdef0123456789abcdef01234567"
files = { "fspec.star" = "sha256-...", "lib.star" = "sha256-..." }
Evaluation never resolves anything. It reads each file at the locked commit from a local cache, or from GitHub with your token, and checks the hash. A load missing from the lock fails with run fspec update. fspec update <pkg> updates packages and leaves every load at its commit.
Inside a loaded file, origin() returns the repository URL and the locked commit, so the recipe can build the project at the same commit it was read from:
def formalmusic():
o = origin()
return pkg("formalmusic", source = git(o.url, rev = o.rev))
gh: loads work only in spec.star and its own loads, not in dev.star. A loaded file names paths (glob, src, patches) relative to your spec, not to its own repository. Building from source covers recipes.
Constraints
- Host names are unique. So are package names, file paths and service names within a host; declaring one twice fails the plan.
image(...)and the distro builtins go insystem, never inmodules.- A file, service or timer declared inside
pkg(files = ..., services = ..., timers = ...)belongs to that package and is removed with it. Declared on its own, it is removed only when its own line goes.