- TypeScript 98.6%
- Svelte 1.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Verify / verify (push) Successful in 43s
Same duplication as the overview's, one level deeper: the Size block listed storage used, free and total as grid values with the "Remote storage" bar directly beneath showing the same figures. The bar stays — one repository is one filesystem, so it means something there — and the restated values go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| backend | ||
| frontend | ||
| CLAUDE.md | ||
| opsdeck.module.json | ||
| README.md | ||
| tsconfig.check.json | ||
module_borg
Monitor and manage borg backups driven by borgmatic, from OpsDeck — on the web and on the phone.
It is a client of the host's existing setup, not a second way to configure
one. borg and borgmatic must already be installed and configured on the host;
this module reads that configuration, reports on the repositories it names, and
runs borgmatic's own actions against them. The named fields it shows —
retention, lock_wait, the sources and the excludes — it can also edit from
the configuration page, through borgmatic's own toolchain and only after
borgmatic's own validator accepts the result (see "Editing the configuration"
below). Everything else in the file it never touches, and it never invents
defaults for anything.
Overview state, last backup, remote capacity, per-repository verdicts
Repository size, encryption, archives with per-archive statistics
Archive when, how long, how many files, what it added to the repository
Configuration the sources as a browsable tree of the host's filesystem,
where clicking a marker changes what is backed up; forms for
excludes, retention and lock_wait; repositories, checks and
database hooks read-only beside them
Backup log the backup unit's journal
Every page is the portable UI schema, so the Android app renders exactly what the web does. The only custom Svelte in here is the dashboard widget, which is the one thing the schema cannot express.
What makes this different from running borgmatic rinfo in a terminal
A borg repository takes an exclusive lock for the whole of a create. On a
real host that is most of the night, and during a first upload it is most of the
week. Every read borg offers — info, list, rinfo — wants a shared lock it
cannot have meanwhile, waits --lock-wait seconds and exits 2:
Failed to create/acquire the lock /home/borg/lock.exclusive (timeout).
A dashboard that asked the repository a question whenever a page was painted would therefore show an error all night — and would make things worse doing it, because a read holding the shared lock is a read that can make the scheduled backup fail to take the exclusive one.
So this module:
- never reads the repository to render a page. Readings happen on a
schedule, are stored through
ctx.storage(so a restart keeps them), and every page shows the age of what it is showing. An old number labelled old is information; an error page is not. - knows a backup is running by looking at the host, not the repository. A
/procscan every 30 seconds findsborgandborgmaticprocesses, including the ones OpsDeck did not start — the nightly systemd timer is the normal case, not the exception. Reads are skipped while one is running. - keeps measuring the thing that still answers.
dfon the far side never opens the repository, so remote capacity keeps updating throughout a backup — and is polled more often while one runs, because watching the far side fill is the only live progress an externally started backup offers at all. - passes a short
--lock-waiton every read (--override lock_wait=…, 5 seconds by default) so nothing ever piles up blocked on a lock. - tells a stale lock from a busy one. A lock nobody on the host is holding is borg having been killed, and only then does the repository page offer to break it.
Consider setting lock_wait: 60 in your borgmatic configuration. Without it a
scheduled backup that starts while this module happens to be reading gets borg's
one-second default and fails on the lock; with it, it waits the reading out. The
configuration page says so when the option is unset.
Watching the remote storage fill up
borg knows how much space its data occupies. It does not know how much space it
is allowed, and only the second number says whether tonight's backup will fit.
Capacity therefore comes from df on whichever side the repository lives:
| Repository | How capacity is measured |
|---|---|
ssh://[user@]host[:port]/path |
ssh … df -P -k <path> on the far side |
[user@]host:path |
the same, scp-style |
/local/path |
df -P -k <path> on the host |
ssh://box/./borg addresses the remote account's home directory and
ssh://box/borg addresses the filesystem root; the two are kept apart. A
ssh_command in the borgmatic configuration is used as given, so a custom
identity or port keeps working. A restricted remote shell that refuses an
argument to df gets a second, bare attempt.
Two alerts come out of it, both deduplicated per repository and both linking to the repository page:
- a threshold: warning at 80% full, error at 92% (
WARN_PCT,CRIT_PCT). - a horizon: a least-squares fit over the module's own 14 days of
repo.used_byteshistory, which warns when it puts the filesystem full within 14 days (FORECAST_DAYS) — before any threshold fires. A fit rather than a first-to-last slope because a repository steps up on every backup and falls on every prune, so two samples can land either side of a pruning cycle and produce a slope of either sign. It answers "—" rather than a number whenever the history cannot support one: a made-up horizon is worse than none, because nobody can tell it from a real one.
If the far side has no usable df at all, set QUOTA_BYTES and the module
measures borg's own deduplicated size against it instead.
The other alerts are a backup that has not happened (MAX_AGE, 26h by default),
readings that have gone stale, and a failed backup, prune or check. Every
verdict carries the sentence that justifies it, shown on hover over the status
dot — a colour whose reason nobody can read is a colour people learn to ignore.
Managing
| Action | Runs |
|---|---|
| Back up now | the host's backup unit if there is one, else borgmatic create |
| Prune and compact | borgmatic prune --stats, then borgmatic compact |
| Check repository | borgmatic check --only repository --force |
| Check archives | borgmatic check --only archives --force |
| Break the stale lock | borgmatic break-lock, only when nothing is running |
| Refresh / Check capacity | the readings, on demand |
All of them are host-owned task runs: they appear in Activity with steps and progress, survive a page reload, and are serialised behind one lock, because one repository can only take one operation at a time.
"Back up now" prefers the host's systemd unit (borgmatic.service by
default) where there is one and the container shares the host PID namespace. A
borgmatic started as OpsDeck's own child is a process inside the container: an
update or a restart kills it, and a first upload measured in days would never
survive one. Started through systemd it runs in a host cgroup, in the same
journal as the nightly run, and outlives anything that happens to OpsDeck. Set
BACKUP_VIA_UNIT=never to always run it directly.
For the same reason there is no backup schedule by default. Leave the
scheduling to the host's timer. SCHEDULE exists for hosts without one, and
sets up an expectation alert alongside it.
Editing the configuration
The configuration page carries forms for the wildcard excludes, the keep_*
retention rules and lock_wait, editing the borgmatic configuration file
directly — on a host with several configuration files it shows a file list
instead, with one edit page per file, because "which file gets this rule" is
then a real question. Paths have exactly one control: the sources tree owns
source_directories and every literal exclude, and the wildcard form holds only
what a tree click cannot express — wildcard patterns and the sh:/re: styles.
The server keeps the two domains apart, so a form submit can never delete a tree
decision and neither control shows the other's entries. Nothing else in the file
is editable, deliberately — repositories in particular stay read-only, because
adding one is borg init and key management, not a form field.
The edit never happens in this process. A borgmatic configuration may carry
encryption_passphrase inline, so the module still never holds the file's text:
the change runs on the host, inside the interpreter borgmatic's own shebang
names, using ruamel.yaml in round-trip mode — the YAML library borgmatic itself
depends on — so comments, key order, quoting and !include tags survive the
edit. The edited document is written beside the original,
borgmatic config validate gets the last word on it, and only a configuration
borgmatic accepts replaces the one it accepted before — by an atomic rename,
with the previous version kept as <file>.opsdeck-bak. A rejected edit reports
borgmatic's own sentence against the form and changes nothing.
The sources, as a tree you can decide in
The configuration page renders the host's filesystem as a lazy, collapsible
tree, rooted at /, read through the host-root mount one directory at a time —
plain listings, no borg and no lock, so it is safe to browse in the middle of a
backup. Only what is on screen is ever fetched; opening a folder fetches its
children then.
Every entry carries a marker: ✓ backed up, ✕ explicitly excluded (with the pattern that did it on hover), ◦ mixed — the folder holds both fates — and an empty box for paths outside every source. Mixed folders open themselves, so the tree unrolls exactly down to the deepest folder where a decision still differs and no further; everything uniform starts collapsed.
The markers are also the input. Clicking one cycles the path: outside the backup
→ a new source_directories entry; backed up → a new exclude_patterns entry
(or, when the path is a source directory, the source is removed); excluded by
an exact pattern → the pattern is lifted. Every click is the same guarded edit
the forms make — validated by borgmatic, backed up as .opsdeck-bak, replaced
atomically. Clicks that have no exact meaning are refused with the reason
instead of guessed at: a path excluded by a wildcard pattern says which pattern
to go edit, a mixed folder says to decide inside it.
Marker verdicts come from this module's implementation of borg's pattern styles
(fm unprefixed, sh:, re:, pp:, pf:) — a preview by the same published
rules; borg on the host remains the authority. One honest limit: a wildcard
pattern marks the entries it matches when they are listed, but cannot mark a
closed folder as mixed from the outside — only literal-path patterns roll up. A
directory of more than SOURCES_ENTRIES entries is capped with a "… n more"
row.
Two things this deliberately does not do:
- Delete individual archives. Retention is borgmatic's
keep_*policy andpruneapplies it. borgmatic gained a first-classdeleteaction only in 1.9, so a button here would be a version-dependent irreversible operation — the wrong combination to ship. - Run the
dataconsistency check. It re-reads every chunk back from the remote, which is a full download of the repository. borgmatic's ownchecksfrequency schedules that; a button would invite someone to start a multi-terabyte transfer by accident.
Requirements
- borg and borgmatic installed on the host (tested against borg 1.2.8 and
borgmatic 1.8.3) with a configuration
borgmatic config validateaccepts. - OpsDeck able to run commands on the host (
ctx.host— normally a chroot into the host-root mount). Without it the module still loads and its pages say exactly which half is missing. - The host PID namespace (
pid: hoston the container) only for the two systemd-flavoured features: the next scheduled run, and starting the backup unit. Everything else works without it; the journal is read from files.
Nothing here needs a YAML parser, because the machine that has borgmatic has
borgmatic's own configuration loader — the module asks it for JSON through the
interpreter borgmatic's shebang names, so a pipx or virtualenv install works
too. It reads no configuration file's text: a borgmatic configuration may
carry encryption_passphrase inline, and redaction over a format with block
scalars and anchors is a denylist over a tarpit. Only named fields are read, and
a secret is never one of them. Edits follow the same rule from the other side:
they run in that same interpreter with ruamel.yaml, and only a receipt naming
the changed keys ever comes back.
Configuration
All optional, all OPSDECK_MOD_BORG_*:
| Variable | Default | What it does |
|---|---|---|
CONFIG |
borgmatic's own defaults | comma-separated config paths (-c) |
HOME |
/root |
HOME the tools run with — where ~/.ssh/config and ~/.cache/borg are found |
PATH |
the usual system path | PATH the tools run with |
ENV |
— | extra KEY=VALUE pairs, comma-separated |
BORGMATIC_BIN / BORG_BIN / SSH_BIN |
borgmatic / borg / ssh |
binaries, if they are not on PATH |
LOCK_WAIT |
5 |
seconds a reading waits for the repository lock |
ARCHIVES |
20 |
archives listed per repository |
WARN_PCT / CRIT_PCT |
80 / 92 |
remote capacity thresholds |
FORECAST_DAYS |
14 |
warn when the projection fills the disk within this many days |
MAX_AGE |
26h |
how old the newest archive may be |
SOURCES_ENTRIES |
250 |
entries listed per directory in the sources tree |
STALE_AFTER |
24h |
how old a reading may be before its age is a warning |
QUOTA_BYTES |
— | remote total to use when df is unavailable |
REFRESH_EVERY / POLL_EVERY |
6h / 1h |
repository reads / capacity checks |
UNIT |
borgmatic.service |
the systemd unit that runs backups |
BACKUP_VIA_UNIT |
auto |
auto, always or never |
SCHEDULE |
— | cron for OpsDeck-driven backups; off by default |
JOURNAL_LINES |
200 |
lines on the log page |
BACKUP_TIMEOUT / PRUNE_TIMEOUT / CHECK_TIMEOUT / REFRESH_TIMEOUT |
24h / 6h / 12h / 10m |
command deadlines |
Metrics
repo.used_bytes, repo.free_bytes, repo.usage_pct, repo.dedup_bytes,
repo.original_bytes, repo.archives and backup.age_sec, each tagged with
the repository id.
Installing
OPSDECK_EXTERNAL_MODULES=https://git.imhof.cloud/OpsDeck/module_borg.git#<sha>
Pin the SHA: a module runs with the server's privileges.
Checks
.forgejo/workflows/ci.yml runs on every push and pull request. Two steps need
nothing but this repository, and they are the two to run while working:
deno fmt --check . # `deno fmt .` fixes it; the `.` is required, since this
deno lint # repo has no deno.json and fmt then refuses to write
The rest need a core checkout — the SDK types, the Svelte runtime, the component library and the module builder all live there:
cp -r . <core>/packages/modules/borg
cd <core>
deno install --frozen
deno check $(find packages/modules/borg -name '*.ts')
deno run -A npm:svelte-check@^4 --workspace packages/modules/borg \
--tsconfig ./tsconfig.check.json --threshold error
deno test --allow-read --allow-write --allow-env packages/modules/borg
deno run -A packages/module-builder/main.ts \
packages/modules/borg /tmp/borg-dist borg
None of those runs register(), and neither does deno check — a module that
typechecks can still be disabled at load by something core only decides then
(an action that declares both console and a target, an invalid UI schema).
The cheapest way to see that is to start core with the module:
OPSDECK_AUTH=disabled OPSDECK_MODULES=borg deno task start
# then look for "module loaded" — or "module disabled" — in the log
The parser tests are built from output recorded on a real host, including the
lock failure captured while a borg create was three hours into an upload. That
message is what the whole "usable during a backup" design turns on, so it is a
fixture rather than a guess.