FormalOS

Services and timers

Declare long-running services and scheduled jobs once, and run them on systemd, launchd, runit, OpenRC, dinit or s6.

service(...) describes a program that runs in the background: what to run, whether to restart it, and what it needs first. fspec renders that description for the host's init system, whether that is systemd, launchd on macOS, or runit, OpenRC, dinit or s6. timer(...) runs a command on a schedule. You write each once and the same line works on every host, as long as it only uses fields that host's init supports.

The host's init comes from its system: systemd for an image(...) host and most distros, runit for void(), launchd on macOS, and whatever you pass as init = ... to a distro builtin. See Hosts and modules.

Run a service

service("syncthing", exec = "syncthing serve --no-browser",
        user = True, restart = "always", after = ["network"]),

On apply, fspec writes the unit, enables it so it starts at boot (or login), and starts it now. The plan shows the command:

$ fspec plan
host laptop
  + service:syncthing  syncthing serve --no-browser
  1 to add, 0 to change, 0 to remove

A changed service is restarted. A removed one is stopped, disabled, and its files deleted.

ArgumentDefaultMeaning
namerequiredLetters, digits and _ . - @. Not starting with . or -
execnoneThe command line. Without it, see Enable a unit the distro ships
userFalseA user service, run as you, instead of a system service run as root
restart"no""no", "on_failure" or "always"
after[]What must start first: "network", another fspec service's name, or a systemd *.target
env{}Environment variables for the command
descriptionnoneOne line. systemd uses the name when it is unset; runit, s6 and launchd have no place for it
systemd{}Extra systemd directives (see Escape hatches)
launchd{}Extra launchd plist keys

System or user

A system service is in the system scope: fspec writes it as root and the init runs it as root. user = True puts it in the user scope: it runs as you, under your login session, and its files live in your home. Use a user service for anything that belongs to you, such as a sync client or an agent that needs your files.

On systemd a user service is a user unit, started by your user manager at login. On macOS it is a LaunchAgent in your GUI session. OpenRC and s6 have no user services.

exec

exec is one line. A bare command name such as syncthing is looked up on PATH through /usr/bin/env, since init systems do not search PATH themselves. Use a full path when the command lives somewhere the init's PATH does not reach, such as /opt/fspec/current/bin/x.

Shell syntax (pipes, redirects, $VAR, globs, ~) works: where the init wants an argument list, fspec runs such a command through /bin/sh -c.

restart

restartsystemdlaunchdrunitOpenRCdinits6
"no"no Restart=RunAtLoad onlya finish script runs sv down .no supervisor, command_backgroundrestart = falsefinish exits 125
"on_failure"Restart=on-failureKeepAlive with SuccessfulExit = falsefinish stops it on exit status 0supervise-daemon, which restarts on any exitrestart = on-failurefinish exits 125 on status 0
"always"Restart=alwaysKeepAlive = truethe runit defaultsupervise-daemonrestart = truethe s6 default

after

after entrysystemdrunit, s6OpenRCdinitlaunchd
"network"After= and Wants=network-online.target (After= only for a user unit)ignoredneed netdepends-on = network for system servicesignored
another service's nameAfter=fspec-<name>.servicerunit: run exits until sv check passes; s6: dependencies.d/fspec-<name>after fspec-<name>depends-on = fspec-<name>ignored
"graphical.target" or another *.targetwritten as isfails planfails planfails planignored

launchd has no ordering between jobs, so it ignores after and description.

env

service("web", exec = "/opt/web/bin/server --port 8080",
        restart = "on_failure", env = {"RUST_LOG": "info"}),

systemd gets Environment= lines, launchd EnvironmentVariables, runit, OpenRC and s6 export lines in their scripts, and dinit a leading /usr/bin/env RUST_LOG=info. Names must be valid shell variable names and values one line.

Escape hatches

Features only one init has go under that init's own argument. On any other init, plan fails and names the field instead of dropping it, so a spec never silently loses a setting.

systemd = {...}

Each key is a [Service] directive. The keys unit, service and install take a dict of directives for that section. A directive you set replaces the one fspec generated with the same name, and a list repeats the directive. Booleans become yes and no.

service(
    "web",
    exec = "/opt/web/bin/server --port 8080",
    restart = "on_failure",
    env = {"RUST_LOG": "info"},
    systemd = {
        "MemoryMax": "512M",
        "unit": {"StartLimitBurst": 3},
    },
),

fspec writes /etc/systemd/system/fspec-web.service:

# Managed by fspec. Edits are overwritten on the next apply.

[Unit]
Description=web
StartLimitBurst=3

[Service]
ExecStart=/opt/web/bin/server --port 8080
Restart=on-failure
Environment="RUST_LOG=info"
MemoryMax=512M

[Install]
WantedBy=multi-user.target

A oneshot that runs at boot and counts as active afterwards:

systemd = {"Type": "oneshot", "RemainAfterExit": True,
           "unit": {"Before": "systemd-user-sessions.service"}},

A user service tied to the graphical session:

service("bar", exec = "waybar", user = True, restart = "always", after = ["graphical-session.target"],
        systemd = {"install": {"WantedBy": "graphical-session.target"}}),

A unit with RefuseManualStart or RefuseManualStop under unit is enabled and disabled but never started, stopped or restarted by apply. It starts with whatever wants it.

launchd = {...}

Raw plist keys merged over the ones fspec writes. Booleans, numbers, strings, lists and dicts map to their plist types.

service(
    "sync-notes",
    exec = "/opt/fspec/current/bin/notes-sync",
    user = True,
    launchd = {
        "StartInterval": 300,
        "StandardOutPath": "/tmp/notes-sync.log",
        "ProcessType": "Background",
    },
),

RunAtLoad = False, KeepAlive = False gives a job that is loaded but idle until you run launchctl kickstart. WatchPaths, ThrottleInterval and LimitLoadToSessionType go in the same way.

A login item that opens an app once at login is a user service with the default restart = "no":

service("login-notes", exec = "/usr/bin/open -g -j -a /Applications/Notes.app", user = True),

Enable a unit the distro ships

Without exec, a service names a unit that a package already installed, and fspec only enables it at boot:

service("sshd"),
service("gdm", display_manager = True),
$ fspec plan
host desktop
  + service:gdm  enable the distro unit
  + service:sshd  enable the distro unit
  2 to add, 0 to change, 0 to remove

fspec does not start the unit, so a new display manager or sshd takes effect at the next boot, and the same apply works inside the root fspec switch --distro builds.

InitEnableDisable on removal
systemdsystemctl enable <name>systemctl disable <name>
runitlinks /etc/sv/<name> into /etc/runit/runsvdir/defaultremoves the link
OpenRCrc-update add <name> defaultrc-update del <name> default
dinitdinitctl enable <name>dinitctl disable <name>
s6, launchdnot supported

Such a service takes no restart, after, env, description or systemd. On systemd it may set user = True to enable a user unit, and the name may carry a suffix such as service("nordvpnd.socket"). Drift is the unit no longer being enabled. Removing it disables the unit and leaves the distro's files alone.

display_manager = True (systemd only) marks the login screen. While it is declared, the host boots to graphical.target. Some distros default to multi-user.target, which never starts a display manager. Plan reports another default as drift, and removing the service puts back the default it replaced.

Services that belong to a package

A service declared on a package goes with it:

pkg(
    "nordvpn",
    source = zypper(repo = "nordvpn"),
    services = [service("nordvpnd.socket"), service("nordvpnd")],
),
pkg("syncthing", services = [
    service("syncthing", exec = "syncthing serve --no-browser", user = True, restart = "always"),
]),

Removing the pkg line stops and removes its services and timers first, then uninstalls the package. pkg(timers = [...]) works the same way. See Packages.

Restart after a package update

On systemd, apply restarts a running service whose program moved to a new store directory. When a pkg update moves /opt/fspec/current/bin/web to a new version, the running process still executes the old one. fspec compares the unit's ExecStart with the process's executable, and if they resolve to different packages in the store, plan reports the service and apply restarts it:

$ fspec plan
host server
  ! service:web drifted: fspec-web.service runs 1a2b3c4d-web-1.4.0, the store now has 5e6f7a8b-web-1.5.0
  0 to add, 1 to change, 0 to remove
$ fspec apply
...
service:web: restarted, fspec-web.service runs 1a2b3c4d-web-1.4.0, the store now has 5e6f7a8b-web-1.5.0

This covers fspec's own services and distro units, system and user, including a user service that runs a package from the system scope. A unit that refuses manual starts or stops is left for its next start. The other inits do not restart a service for this.

Run a command on a schedule

timer("backup", exec = "restic backup ~/Documents", schedule = "30 2 * * *", user = True),
timer("gc", exec = "/usr/local/bin/fspec gc --older-than 14d", schedule = "@weekly"),
ArgumentDefaultMeaning
namerequiredAs for a service. No . on hosts whose timers are cron entries
execrequiredThe command
schedulerequiredFive cron fields (minute hour day-of-month month day-of-week), or @hourly, @daily, @weekly, @monthly
userFalseRun as you
env{}Environment variables, on every init

Fields take numbers, *, ranges (1-5), lists (1,15), steps (*/10, 0-30/5), and month and day names (jan, mon). A schedule that restricts both day-of-month and day-of-week fails plan: cron runs the job when either matches, systemd only when both do, so fspec refuses to guess.

InitA timer becomes
systemdfspec-<name>.timer with OnCalendar= and Persistent=true, plus a oneshot fspec-<name>.service. A run missed while the machine was off happens at the next boot
launchdA fspec.<name> plist with one StartCalendarInterval entry per combination of listed values, at most 64
runit, OpenRC, dinit, s6A cron entry, schedule as written

A cron system timer is /etc/cron.d/fspec-<name>, run as root. A user timer is a block in your crontab between # BEGIN fspec:<name> and # END fspec:<name>; fspec leaves the rest of the crontab alone. fspec expects a cron that reads /etc/cron.d, such as cronie or vixie-cron. dcron, busybox crond and snooze are not supported.

Where each init's files go

InitSystem serviceUser serviceTimerNot supported
systemd/etc/systemd/system/fspec-<name>.service~/.config/systemd/user/fspec-<name>.servicefspec-<name>.timer next to its .servicelaunchd = {...}
launchd/Library/LaunchDaemons/fspec.<name>.plist~/Library/LaunchAgents/fspec.<name>.plista fspec.<name> plistservices without exec, systemd = {...}. after and description are ignored
runit/etc/sv/fspec-<name>/run, linked into /etc/runit/runsvdir/default~/.local/service/fspec-<name>/runcron*.target in after, systemd, launchd
OpenRC/etc/init.d/fspec-<name>nonecronuser = True, *.target, systemd, launchd
dinit/etc/dinit.d/fspec-<name>~/.config/dinit.d/fspec-<name>cron*.target, systemd, launchd
s6/etc/s6/sv/fspec-<name>/, compiled into /etc/s6/db/nonecronuser = True, services without exec, *.target, systemd, launchd
cron (timers on the four above)/etc/cron.d/fspec-<name>a block in the user's crontab. in the timer name

systemd units, OpenRC scripts, dinit files and cron files start with # Managed by fspec. Edits are overwritten on the next apply. An edit to any file fspec rendered shows as drift (... was edited), and the next apply writes the file again.

Init details

A unit file you write yourself with file(...) under /etc/systemd/system or ~/.config/systemd/user also gets a daemon-reload, but fspec does not enable it. Pair it with service("<unit name>") to enable it.

Troubleshooting

ErrorFix
service:web sets `systemd` (MemoryMax), which only systemd supports, and this host uses runitMove the field into a module that only systemd hosts get, for example if host.init == "systemd"
service:web sets user = True, but OpenRC has no user servicesMake it a system service on that host. s6 gives the same error
service:web orders itself after `graphical.target`, a systemd target, and this host uses runitDrop the target on non-systemd hosts
service:gdm has no exec, and enabling a unit the system ships is not supported on launchdDistro units exist on Linux only. s6 gives the same error
service:gdm has no exec, so it enables the distro's own unit, which takes no restartA service without exec only takes name, user and display_manager
service:gdm has no exec, but /etc/sv/gdm does not exist; install the package that ships itrunit: add the package that ships the service directory
timer name `backup.daily` holds a `.`, which cron ignores in /etc/cron.d file namesRename the timer
schedule `0 0 1 * 1` restricts both day-of-month and day-of-week; cron runs on either, and no init here can express thatUse two timers, or restrict one field
schedule `@yearly` is not supported, use @hourly, @daily, @weekly, @monthly or five cron fieldsWrite 0 0 1 1 *
schedule expands to 70 launchd calendar entries, more than the 64 fspec writeslaunchd: simplify the schedule, or use launchd = {"StartInterval": ...}
service name `my service` may only hold letters, digits and `_ . - @`, and must not start with `.` or `-` Rename the service