Skip to content

About

A local, per-machine scheduler and orchestrator for rustic backups

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

rusticprofile

A local, per-machine scheduler and orchestrator for rustic backups.

Note: The name is a lineage marker, not a compatibility claim. rusticprofile makes no compatibility promises with resticprofile — not the config schema, not the CLI, not the exit codes, not the output. It cannot read a profiles.yaml.

Note: This project was 100% vibe coded. Real programmers are welcome.

Status

Milestones 1, 2, 3, 5 and 6 complete. Repository lock coordination (M4) is the only unbuilt one, and it is deferred deliberately rather than pending — it is defence in depth, not a precondition, because rustic is lock-free by design and its prune leaves a 23-hour grace period. The box further down is what actually matters for safety. rusticprofile can validate a configuration, show exactly what it would run, run it — taking a local lock, sequencing operations, classifying what rustic reports and summarising the result — schedule itself with systemd on Linux, launchd on macOS or Task Scheduler on Windows, and report what it has been doing: a per-run log, a status file recording when each job last succeeded, and --json for anything automated.

Linux, macOS and Windows. schedule installs systemd units on Linux, a launchd agent on macOS, or a Task Scheduler task on Windows, and refuses anywhere else rather than writing units nothing will run. One limitation is worth knowing before you rely on it, and it applies to both macOS and Windows: a user-level schedule runs only while you are logged on, because neither platform has an equivalent of systemd's linger. So a Mac at the login window or a PC at the lock screen takes no backups — nothing fails, and the only evidence is an absence. Watch last success, or use permission: system. On Windows that same interactive logon is why a scheduled run would otherwise open a terminal window every time it fires; schedule passes --background so the run detaches from its console instead. See What it does not do yet below, which you should read before pointing several machines at one repository.

Today:

rusticprofile config --example jobs                  # an annotated starting-point config
rusticprofile config --example rustic                # ...and the rustic side, annotated at length
rusticprofile config --check                         # validate; every problem at once
rusticprofile config --check --as-host host-a.local  # another machine's view of the gates
rusticprofile config --show -n dot-files             # the resolved form of one job
rusticprofile plan -n dot-files                      # the exact rustic argv, without running it
rusticprofile plan                                   # ...or omit -n to use defaults.default-job
rusticprofile plan -n dot-files --format lines       # one argv element per line
rusticprofile plan -n dot-files --show-env           # ...plus the environment, secrets masked
rusticprofile run -n dot-files --dry-run             # what a run would do, writing nothing
rusticprofile run -n dot-files                       # actually run it
rusticprofile run -n dot-files --json                # ...reporting as JSON, for a wrapper

rusticprofile schedule -n dot-files                  # install and arm this platform's schedule
rusticprofile schedule -n dot-files --write-only     # install it inert, to read first
rusticprofile status                                 # what is scheduled here, plus last run / last success
rusticprofile status --json                          # ...for a monitor; alert on last_success
rusticprofile snapshots -n dot-files                 # list snapshots (read-only passthrough to rustic)
rusticprofile retention -n dot-files                 # how far back hourly/daily/weekly/monthly/yearly still reach
rusticprofile retention -n dot-files --near 2026-05-15   # ...and what sits either side of a date
rusticprofile retention -n dot-files --all           # ...and every snapshot with its reasons, oldest first
rusticprofile unschedule -n dot-files                # remove the units

rusticprofile doctor                                 # local checks: competing prune schedule, missing secrets, stale units
rusticprofile doctor --repository                    # ...plus: is anything else writing retention here?

schedule and unschedule are each a single step, and each fully undoes the other. What a job becomes depends on the platform: two systemd units on Linux — a .timer and the .service it activates, because systemd has no way for a timer to run a command directly, which is true of every tool that schedules with systemd — one launchd agent on macOS, or one registered task on Windows. On Linux only the timer is enable-able: the service is static, so it cannot be armed on its own and run outside its schedule.

config and plan are hermetic — no rustic binary, no repository, no network — so they are safe to run anywhere. doctor is the deliberate counterpart: it exists precisely to check what a hermetic command cannot, because the failures that have actually cost data here were configurations that were individually correct and only wrong in combination with another tool. Its default run is still local and instant; --repository is opt-in because it is the one check within doctor that needs the network and a credential. run is the one that changes a repository; snapshots and retention read one, so both need the network and the credential too — retention always as a dry run. schedule is the one that talks to the service manager (systemctl, launchctl or schtasks). Running the binary with no arguments exits non-zero and says so rather than doing anything by default.

What it does

rusticprofile owns when backups run, which jobs exist, and on which hosts:

  • systemd units on Linux, launchd agents on macOS, Task Scheduler tasks on Windows — install, remove, status
  • per-host job gating, so one machine runs the prune job and the others do not
  • operation sequencing within a job (backup, then forget)
  • exit classification, including telling a partial backup apart from a failed one
  • refusing configurations that would quietly do less than they say
  • on Linux and Windows, retrying a failed operation twice at two-minute intervals when the run was started by the scheduler — a missed hourly slot is replayed within seconds of the machine waking, which is before the network is back, so without this the run that replaces a missed hour is the one most likely to fail. Measured on both platforms: on Linux the catch-up fired in the same second as the resume and died on DNS, while the network became usable 11 seconds later. A run you type yourself still fails immediately. macOS is not covered — the same race is plausible there and has not been measured, and this project does not generalise a mechanism from an unmeasured platform
  • recording what happened: a per-run log, and a status file whose last_success survives a failed run — the one field that reveals a job which has quietly stopped working, since a schedule can be armed and green while every run fails
  • doctor: whether a restic prune schedule is still armed here (the one combination measured to corrupt the repository), whether the credential files the profile names exist, whether each installed unit is what this binary would write, and — with --repository — whether anything else has been writing retention for a host
  • retention: how far back each retention period still reaches, what a forget would remove, and — with --near <DATE> — which snapshots sit either side of a date and how far off they are. It answers the question that sends you to a backup in the first place: I need a file from around then; what can I actually get? Retention makes snapshot density non-uniform — the last day hour by hour, the last week day by day, the last year month by month — so knowing which resolution survives at a given age is the thing worth knowing. rustic computes all of it: its forget --dry-run reports a reason per kept snapshot, and nothing is recomputed here. Always a dry run, and not because a flag says so — the argv is the scheduled forget's own code path with --dry-run fixed in place, so the preview cannot differ from the operation it previews and cannot be turned into one that deletes
  • --json on run, status and doctor, so a monitor never has to match English

What it does not do yet

These are planned and not written. They are listed here rather than in the section above because a backup tool's README is a safety surface, and an aspirational feature list is a way to lose data.

  • Backups while nobody is logged in, on macOS or Windows. Only systemd has linger. A user LaunchAgent runs only inside a login session, and a Windows user task only while you are logged on — so a Mac at the login window or a PC at the lock screen takes no backups. Nothing fails, and the only evidence is an absence. Watch last_success, or use permission: system, which runs regardless of login as root or SYSTEM. schedule and status both state this. On Windows, running as yourself without a login session would need a stored password or S4U, which relocates the credential problem rather than removing it — so it is deliberately not offered.
  • A next-fire time on macOS. launchd does not report one, so next_run is null there and status says why. status --json names the backend so a monitor can tell "launchd never tells" from "could not read the timer". Windows and Linux both report a real one.
  • Repository lock coordination. run takes a local per-job lock so two runs on one machine cannot overlap. There is no cross-machine coordination, and you should read the next paragraph before pointing several machines at one repository.

Warning

Do not run restic prune against a repository that rustic may be writing to.

This is about mixing the two tools, not about rustic. rustic is lock-free by design and it works: prune marks packs and only deletes them after --keep-delete, 23 hours by default, so a concurrent rustic backup has a day of grace. Verified — a default rustic prune left every pack on disk and only --instant-delete removed them.

restic reaches the same safety a different way: an exclusive lock object inside the repository, which lets it delete immediately. rustic does not take that lock, so the two schemes do not compose.

safe?
rustic prune + rustic backup yes 23h grace period
restic prune + restic backup yes restic's repository lock
restic prune + rustic backup no restic deletes at once, relying on a lock rustic never takes

Measured for that last row: with a rustic backup in flight, restic prune did not wait. It deleted 14 packs as unreferenced, and restic check --read-data afterwards reported "The repository is damaged and must be repaired."

If everything touching the repository is rustic, none of this applies. NOTES.md §6.8 has the measurements.

What it deliberately does not do

Backup configuration is delegated entirely to rustic's own rustic.toml, which already covers the repository, source paths, excludes, retention policy, hooks, environment and Prometheus metrics — natively, with env > config > CLI precedence.

rusticprofile constructs no backup flags. A job resolves to:

rustic -P <resolved-profile-path> <operation> [--dry-run] [--json] [--name <set>]...

That is the whole contract. A wrapper that re-specified rustic's options would mostly reinvent rustic.

Also out of scope for v1: reading resticprofile config, a migrate command, restic as a backend, cron, groups, hooks, metrics, and templating in any form — including a "just one small conditional" escape hatch.

Windows was on that list until 0.2.0 and is not any more: the machine this project is developed and released from now runs it. NOTES.md §6.10 records the reversal and what had to be measured to make it work. cron is still out.

Restore is not here either. Use rustic restore directly. Putting a restore path behind a scheduler adds a layer between you and your data at the exact moment you least want one.

Why it exists

rustic_scheduler already exists in the rustic organisation, but it is client/server: an always-on central scheduling server with clients attached over websockets. That is the wrong shape for a fleet of intermittently-online personal machines. The genuinely unfilled gap is local, per-machine OS-level scheduling with per-host variation and no server at all.

The design also carries hard-won opinions from operating its Go predecessor, where four separate bugs each caused a silent degradation — retention that matched zero snapshots for months, a backup that aborted before retention because two source paths did not exist, a config that failed to load because of a template directive inside a comment, and a config block that had never taken effect at all. For a backup tool, silent is the failure class that matters. So:

  • unknown config keys are a hard error, not a shrug
  • validation is batched and happens before any process is spawned, with exit 2 kept distinct from "the backup failed"
  • a job that would do nothing on this host is an error unless the config explicitly says it should do nothing here
  • no shell is ever involved — commands are built as an argv and spawned directly, which deletes an entire category of quoting and secret-leaking bugs

NOTES.md §6 has the full reasoning, every rejected alternative, and the measurements behind each decision.

Getting a configuration

Two annotated files, written to stdout:

rusticprofile config --example jobs   > ~/.config/rusticprofile/jobs.yaml
rusticprofile config --example rustic > ~/.config/rustic/dot-files.toml
rusticprofile config --check

The second one is the one to read properly. Because rusticprofile delegates all backup configuration to rustic, nearly everything that can silently lose data lives in that file — exclusion globs are include filters unless prefixed with !, a scoping filter under [forget] is accepted and then ignored, and retention grouped by host alone lets a 0-byte snapshot evict a 395 MiB one. Every annotation in it records a measured failure.

Placeholders (host-a, /home/user) are static and deliberately not filled in for you.

Configuration sketch

# ~/.config/rusticprofile/jobs.yaml
schema: 1

defaults:
  default-job: dot-files            # used when a command is given no -n

jobs:
  dot-files:
    profile: dot-files              # -> rustic -P dot-files
    operations: [backup, forget]
    snapshot-sets:
      - name: core
      - name: gnupg
        enabled-on-hosts: [host-a, host-b]
    schedule:
      at: hourly
      permission: user
      priority: background
    log: "${state_dir}/${job}-${date:%Y-%m-%d}.log"

Note what is not in there: no source paths, no excludes, no retention numbers, no repository. Those live in rustic.toml.

${state_dir} is $XDG_STATE_HOME/rusticprofile — logs are state, not configuration. Pointing them at ${config_dir} is also self-defeating if ~/.config is one of your backup sources: the job then appends to a directory it is busy backing up.

The XDG variables are honoured on macOS and Windows too, falling back to ~/.config and ~/.local/state rather than ~/Library/Application Support or %APPDATA% (0.1.25+, Windows in 0.2.0+). On Windows note that a path with no drive letter counts as relative, so a rooted-but-driveless log: is refused there — and HOME is normally unset, so a jobs.yaml written around ${env:HOME} will not load until you set it. That is deliberate, and it follows from the box below: a jobs.yaml meant to be byte-identical across a fleet cannot have ${state_dir} resolve to a different place depending on the operating system reading it.

default-job is honoured by run, plan, snapshots, retention and config --show — not by unschedule, where removal is always named explicitly, nor by schedule, where omitting -n already means "every job that declares a schedule".

Important

jobs.yaml is designed to be byte-identical across a fleet, which makes it only ever as new as the oldest binary reading it. Unknown keys and variables are hard errors by design, so a config using ${state_dir} (0.1.15+) or default-job (0.1.20+) will not load on an older build — and that host stops backing up at its next scheduled run. Upgrade the binaries before pushing the config.

Important

Re-run schedule after upgrading rusticprofile. The installed unit, agent or task is generated once, when you arm it — nothing re-emits it when the binary changes, and status will keep reporting the schedule as active because it is. So an upgrade can leave you running a unit written by a much older version. This is not hypothetical: a host in this project's own fleet spent eight days failing the first run after every boot on a unit that predated 0.1.10, while every later run in the hour succeeded (NOTES.md §6.11). schedule is idempotent — it reports unchanged when there is nothing to do, and on systemd it does not trigger a run — so re-running it costs nothing. rusticprofile doctor reports a stale unit as units-current, naming the lines that differ.

Installation

rustic itself is a separate prerequisite. rusticprofile spawns rustic — it does not vendor, bundle or depend on it as a crate, so no route below brings it with them except the Arch package, which declares it as a dependency. This is not a theoretical footnote: a host in this project's own fleet had cargo and restic and no rustic at all, and the failure surfaces at the first scheduled run rather than at install time.

cargo install rustic-rs      # or your distribution's rustic package

From crates.io — the shortest route, published every release since v0.1.0:

cargo install rusticprofile

That installs the binary only. The man page and shell completions come from the Arch package or a checkout, both below.

On Arch Linux — from the AUR, where it is maintained by this project:

paru -S rusticprofile        # or your AUR helper of choice

This is the shortest route that also brings the man page, completions and rustic. The package declares depends=('gcc-libs' 'rustic'), so an AUR helper installs rustic from Arch's own repositories alongside it — the one route where the prerequisite box above does not apply. Its payload is the binary, the gzipped man page, LICENSE, README.md, and completions for bash, fish and zsh.

It tracks the newest released tag, not main, so it can sit a release behind while the repository moves on — that is by design, not staleness. pacman -Qi rusticprofile against the latest release is the check.

On Fedora Linux — from the COPR repository:

sudo dnf copr enable kentobias/rusticprofile
sudo dnf install rusticprofile

The package installs the binary, man page, and completions for bash, fish, and zsh. It tracks the newest released tag on Fedora 43 and 44 (x86_64 and aarch64). Note that rustic is recommended but not packaged in Fedora's base repositories, so install rustic separately (e.g. via cargo install rustic-rs) if not already present.

A prebuilt binary — every release ships five targets plus the man page, from the latest release:

platform asset
Linux rusticprofile-linux-x86_64, rusticprofile-linux-arm64
macOS rusticprofile-macos-arm64
Windows rusticprofile-windows-x86_64.exe, rusticprofile-windows-arm64.exe

Windows on arm64 is deliberately included rather than left to cargo: it is precisely the machine least likely to have a Rust toolchain on it, so "supported" without a binary would mean supported for nobody who needs it.

From a checkout — the only route that installs completions for all six shells, and the only one that works from an arbitrary commit rather than a release:

git clone https://github.com/l1a/rusticprofile.git
cd rusticprofile
just install               # this checkout: binary + man page + completions for six shells
just install-tag 0.2.22    # a released tag instead, all three FROM that tag

Requires just and Python 3. just install installs the committed man page, so mandown is needed only to regenerate it (just man).

install-tag is the one to use for an upgrade, and the reason is that cargo install on its own replaces only the binary. A host in this project's own fleet was found running a current binary beside a man page eleven releases old, because every upgrade had been a hand-typed cargo install --git --tag and nothing re-ran the other two recipes or reported that they were stale — the same shape as a generated systemd unit that no upgrade re-emits.

It takes care to make all three artefacts agree, which is the part worth knowing: the binary comes from the tag, the completions are generated by the installed binary so they cannot disagree with its CLI, and the man page is read out of the tag (git show v<version>:…) rather than out of your working tree. Installing a v0.2.22 binary next to a v0.2.23 man page because the checkout had moved on is exactly the kind of individually-plausible mismatch this tool exists to refuse.

install-completions tells you whether each shell will actually load them. zsh is the one that usually will not: unlike fish and bash-completion, it reads no user directory unless fpath names it, so the recipe checks and prints the fpath+=(…) line to add rather than claiming success.

PowerShell is in the set and works on Windows — verified by generating the script, dot-sourcing it and asking TabExpansion2, not merely by the file appearing. It needs one explicit step and the recipe says so: PowerShell loads nothing automatically, so dot-source the file from your $PROFILE.

just install-completions needs no PATH setup on Windows, unlike just check and just man. The work is done by scripts/install_completions.py, so it needs neither cygpath, nor bash, nor an sh on PATH — only a Python interpreter. That mechanism comes from retch, which adopted it for exactly this reason; 0.2.23 made it the shared standard across all three repos and moved this repo onto it. Until 0.2.23 this was a plain rather than a shebang recipe, which also worked but still needed an sh. This paragraph said the opposite until 0.2.14 ("runs on Windows once Git's usr\bin is on PATH"), contradicting the recipe body's own comment two files away; the sentence was written when that requirement was real and was not revisited when 0.2.2 removed it.

Development

just setup    # install git hooks (pre-push runs fmt + clippy)
just check    # cargo fmt --check + cargo clippy --all-targets -D warnings
just test
just open-pr  # runs the full pre-PR gate, then gh pr create

See CONTRIBUTING.md, and NOTES.md for current state, the backlog and the lessons behind the design.

License

GPL-3.0-or-later. See LICENSE.

About

A local, per-machine scheduler and orchestrator for rustic backups

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages