retnd mark: a broken ring standing for a transfer cycle in progress

retnd

/rɪˈteɪnd/ pronounced ri-TAYND · “retained”

Pulls finished backups off a remote server, verifies them, commits them to the NAS, and only then deletes the original.

Go 1.27+ Apache-2.0 licence Container image 0.4.0 is cut and not pushed; 0.3.3 is the newest published image

A backup producer somewhere writes a dump, an archive or a snapshot to disk. That machine has finite space, so something has to move the artifact off it and something has to delete the original. retnd is the half of that job that runs on the NAS: it discovers finished artifacts on the remote server over SFTP, pulls them, verifies them, commits them durably, records that it did, and only then removes the remote copy.

It is a standalone Go binary that embeds pinned rclone Go packages. It does not fork rclone, and it does not shell out to the rclone command line for normal data movement.

Installing it

Two commands, no arguments, on any machine with Docker:

curl -fsSLO https://raw.githubusercontent.com/retnd/retnd/main/scripts/install/install_docker_host.py
python3 install_docker_host.py install

Output

==> Installed.
    Web UI:  http://10.0.0.10:8080
    Compose: docker compose -p retnd --env-file /home/you/retnd/.env -f /home/you/retnd/compose.yaml -f /home/you/retnd/compose.image.yaml

    No config.yaml was written, on purpose. Issue #176 shipped a first-run setup flow
    precisely so that a fresh install does not need one hand-written before it starts.
    Open this link and follow it:

    retnd-web: no administrator account exists yet. Open http://10.0.0.10:8080/enroll?token=4zj7VCpcYLIeVNN1oZJZPaCErYXOc6s6 to create one (valid 30 minutes, single use).

    If it lapses before you get to it, `enroll-link` issues another.

That is the whole of it. The installer picks every path for you (~/retnd, with backups, state, config and secrets under it), generates the SSH key the engine will use, pins the exact image it was built against, and refuses before it changes anything if the machine cannot run it. Nothing needs deciding up front, and everything it chose can be changed afterwards.

The last lines are the ones that matter, and they are output rather than something to run. The engine mints the enrolment token during startup and writes the notice to its own log, so the installer waits for that line and hands it over itself (#803): a fresh install ends with the link, and opening it is the next step. Nothing has to be fetched by hand, and if the notice had not been written by the time the installer stopped waiting — on a deployment already behaving oddly — the install prints the one command that reads it back out of the engine's log instead, with every path in it the one it just chose.

The address in the link is this machine's own rather than localhost, because the link is read from another machine on the same network: localhost over SSH on a laptop is the laptop, which is what the installer used to print. Open it, create the administrator — a username, a password, a recovery email address and the mail server to reach it through, which enrolment proves by sending a confirmation message before it creates anything — and the first-run tutorial picks up from there. Have the SMTP details to hand before you open the link: the form will not finish without a working one.

If the link has expired

The token lasts 30 minutes and works once, and nothing re-issues it while the engine keeps running. One command mints a fresh one:

python3 install_docker_host.py enroll-link

Output

==> Restarting retnd, which is what mints a token
==> Waiting up to 90s for the new notice

retnd-web: no administrator account exists yet. Open http://10.0.0.10:8080/enroll?token=jdurSlionp6jyoHTzP_5fHYfLy3Kk6tq to create one (valid 30 minutes, single use).

    Valid 30 minutes, and it works once. Every link printed before this one is now dead,
    including any still in your scrollback.

It reads back the newest notice rather than the first, because the container keeps its log across the restart and the earlier link is the one that restart just killed. On a deployment that already has an administrator it refuses instead, with its own exit code: enrolment is a one-time door and it closed when that account was created, so sign in. Forgotten the password rather than lapsed a link? That is Forgot password on the sign-in page, not this command: it mails a single-use reset link to the account's recovery address.

Command line only, no web interface

Same two commands, one flag on the second one:

curl -fsSLO https://raw.githubusercontent.com/retnd/retnd/main/scripts/install/install_docker_host.py
python3 install_docker_host.py install --cli-only

Output

==> Installed. Nothing is running yet, and that is what --cli-only means here:
    `retnd daemon` is refused rather than started without a config.yaml, and a CLI-only
    install has no first-run wizard to write one. Creating the first backup set writes
    the first config.yaml with it:
      /home/you/retnd/bin/retnd backup-set create <source>/<backup-set> \
          --host HOST --user USER --remote-path /remote/path --local-path /backups/path \
          --ssh-key-file /etc/retnd/id_ed25519 --trust-host-key \
          --completion-strategy stable

    Then start the scheduler:
      docker compose -p retnd --env-file /home/you/retnd/.env -f /home/you/retnd/compose.yaml -f /home/you/retnd/compose.image.yaml up -d --no-build retnd

    CLI:     /home/you/retnd/bin/retnd
    Compose: docker compose -p retnd --env-file /home/you/retnd/.env -f /home/you/retnd/compose.yaml -f /home/you/retnd/compose.image.yaml

No enrolment link there, and nothing to click: a CLI-only deployment has no Web UI to enrol into.

That runs the engine as retnd daemon rather than retnd-web serve, never starts the web-ui container, and publishes no port on this host at all, so nothing in the deployment serves HTTP and the retnd-web binary is never executed. What you drive it with is the retnd wrapper the installer writes to ~/retnd/bin/retnd, which takes every command in the table below.

There is no first-run wizard either, which is why a fresh CLI-only host stages everything, starts nothing, and prints the one command that writes the first configuration: creating the first backup set writes the first config.yaml along with it. Re-running the installer later with no flags keeps the deployment this shape, and --no-cli-only converts it to the full stack.

If you would rather see what either of those would do before it does it, python3 install_docker_host.py preflight runs every check and installs nothing.

What it sets up

There is one product here, not eleven. Every supported platform wraps the same multi-architecture image and the same Compose topology, and the differences between them are host paths and metadata formats.

Two services, one image. retnd runs the engine, the scheduler, local authentication and /api/v1 in one process, with no published port at all. web-ui serves the static interface and reverse-proxies the API to it, and it is the only service with a LAN-facing port. They meet on a private project-scoped bridge network, which is what makes the engine's isolation a topology rather than a convention.

Only if you are writing the Compose file yourself

The installer above already does this. If you are standing the stack up by hand instead: the runtime image is distroless and has no root step, and a bind mount does not chown its source, so create the host directories and chown them to the container's uid and gid before the first docker compose up, or the engine will not be able to write its own configuration, keys or journal.

The configuration directory in particular is mounted writable, as a directory rather than a single file, because a directory is the only shape that can honestly be empty. That is what makes an install that has never been configured reach the setup screen instead of refusing to start. The first-run tutorial picks up from there.

Tutorial

Setting one up for the first time means a one-time enrolment link the install hands you, an administrator account that is not your NAS login — with a recovery email address and the SMTP details to reach it, both proved by a confirmation message before the account exists — and an eight-step wizard whose answers become a config.yaml you will live with. The first-run walkthrough shows every screen, with an example for every field and a sentence on what that entry actually causes the product to do.

Walk the first run →
Reference

Every screen of the web interface with a picture and a table of what each control does, every one of the twenty-four retnd commands, and every subcommand and flag of the installer with its default. The command table and the flag table are both checked against the code on every run of the gate, so a command or a flag that lands without a row fails the build rather than going quietly stale.

Read the reference →
Guide

Bash scripts around every backup: the five stages and the one order they run in, why a .local.sh hook executes outside the engine's container, why a .remote.sh hook needs a credential your SFTP account deliberately is not, the environment and secret model, the shell verification that refuses a save rather than a run, and what a crash leaves behind — a blocked backup set with two ways out and no dismiss.

Read the workflows guide →

What 0.4.0 added

0.4.0 is one idea and two corrections. The idea is that a destination is an instance of a registered backend rather than one of a fixed pair, and nearly everything new here follows from taking that seriously. The corrections are what running 0.3.3 on real hardware turned up, and the first of them is a reason to upgrade rather than a nicety.

A backend is described by a manifest the build carries — data, not code — and the manifest is what decides which fields a destination has, which steps its connection test runs, and what the steps it skips say instead. Two are registered: local_volume, a directory on a disk this machine can see, and s3, a bucket on Amazon's service or anything that speaks its API. The drive backups already land on is an instance too, seeded on a fresh install as a declared destination called local, so it is a row in the same list with the same controls rather than an unnamed default the interface had no way to talk about. A deployment can hold two volumes and two buckets now, and the list can tell them apart.

Two wizards come with it. Add a destination is three steps — choose a backend, name the instance, confirm — and writes nothing at all, which the confirm step says in as many words. The backends it offers come from GET /api/v1/backends, so there is no list of backend names anywhere in the page, and a backend this build understands with no manifest behind it is drawn and refused rather than quietly left out. Configure is the manifest-driven half: one renderer, not a form per backend, and nothing in the page knows what a local volume asks for. It fills in what the manifest declares, proves it, and only then writes. That order is enforced rather than advised — editing any field makes an earlier pass stale, the screen says so where the edit was made, and Save configuration stays out of reach until a check against the values now on screen has passed — and the consequence an operator can use is that no retention tier can be pointed at a destination nobody has proved.

Handing the Default mark from one destination to another is its own confirmation, because only one of the two things it does is the thing that was clicked: the destination taking the mark stops being removable, and the one giving it up becomes removable. Nothing already written moves. Declaring a destination moves nothing either; backups arrive somewhere only once a retention tier names it, which stays a separate decision.

The first correction is #662, measured on 0.3.3 on the NAS this installer was proven on. A completed local copy could be recorded as zero bytes: whatever the backend reported for the read-back went into the journal and into the sidecar recovery manifest — the record a catalog rebuild trusts when the journal is gone — without ever being checked against the file. Reconciliation then found two journal fields disagreeing with each other and quarantined the intact file over it, without reading the file. And there was no way back out: retry and fetch looped, the artifact was re-discovered as already known, and the refusal named no verb that would resolve it. Commit now records the bytes it actually made durable, a transfer that reports less than its object does not carry the zero forward, a disagreement between two records is treated as a fault in the records rather than as a verdict about a file, and from a deployment already in that state some documented verb ends with the artifact at a durable restore point.

The second is the surface that reported it. The activity feed keyed a run's severity on the destination it was writing to, so a real failure could read as ordinary; it keys on the transition now, and a FAILED backup has a control that reaches a recovery verb instead of a message recommending a command the same window called nonexistent.

The last piece is the first ten minutes. What the installer prints is transcribed from the installer on every run of the gate rather than written beside it (#714), so the output blocks above are the real ones; and the enrolment link names this machine's own address rather than localhost, which on a laptop reading the log over SSH is the laptop (#688). enroll-link reissues one when the thirty minutes have passed.

If you are running 0.3.3, this is the upgrade

The zero-byte record above is not cosmetic. The file on disk was intact and both records that describe it said it was empty, which is the pair a rebuild would have to choose between. 0.4.0 stops producing that state and leaves a way out of one already reached; 0.3.3 has neither.

The rule everything else serves

Invariant

A remote backup artifact must not be deleted until a verified and durably committed NAS copy exists. If state is uncertain, the remote copy is preserved.

Every design decision below is either enforcing that rule or admitting where the enforcement does not exist yet. It is why the delete comes last, why the journal is written before the delete rather than after it, and why a changed SSH host key halts a backup set instead of carrying on.

What one artifact goes through

The stages below are the ones the interface names, and the order is fixed. Deletion of the remote source is the last thing that happens, after the local copy has been written, verified, fsynced and recorded.

StageWhat happens
DiscoveredThe artifact appears in the remote folder and matches the include patterns. Nothing is copied until the completion method says the producer has finished writing it.
TransferredPulled to the NAS destination over SFTP.
VerifiedTransfer verification, then a SHA-256 checksum, then the application validator if the backup set names one. A validator that rejects an artifact quarantines it and leaves the remote copy alone.
CommittedThe local copy is made durable, and a sidecar recovery manifest is written beside it so the catalog can be rebuilt if the state database is ever lost.
Safe state persistedThe lifecycle journal records that a good NAS copy exists. This write happens before the delete, not after, so a crash in between leaves two copies rather than none.
Remote artifact deletedThe remote source is removed, with a check that it is still the same file that was verified.

Where a copy goes after that, and how long it is kept, is the retention policy's business. The reference page covers the destinations and the tiers that name them.

The command line

Everything the web interface does, and several things it does not, are reachable from retnd. Every command except version takes --config, defaulting to /etc/retnd/config/config.yaml, which is a file inside the config directory rather than a file mounted on its own; pass the directory and it resolves config.yaml inside it.

CommandWhat it does
runperform one processing cycle and exit
daemonrepeat the processing cycle at poll_interval
checkvalidate config and the state database, then exit
statusreport process and backup-set health, exiting non-zero unless every set is healthy
sourceslist configured sources and backup sets
backup-setcreate, patch, remove and test-connection one backup set, and take or release the hold the web editor takes on it
activitylist recorded lifecycle events, or tail the live feed with --follow
artifactslist journal artifacts, optionally filtered by source and backup set
fetchrun one backup set's cycle on demand
retentionpreview retention decisions, with per-run policy overrides; retention apply carries a previewed plan out
mediumthe storage destinations: list, show, add, edit, remove, default, import-credentials and test-connection
settingsread the deployment's retention and capacity settings, and patch them in place
reconcilereconcile the journal against what is actually on disk, for every backup set
validatere-check one artifact's durable copy, wherever it is; --content downloads a copy on a storage medium and re-hashes it
catalogcatalog rebuild reconstructs a lost or corrupted state database from the sidecar recovery manifests
quarantinerevalidate, retry or reinstate one quarantined artifact
retryput one FAILED artifact back into the pipeline; failed is not quarantined, so it is its own verb and nothing does it automatically
unconfiguredlist the backup sets the journal remembers and the configuration no longer names, and what they still occupy; unconfigured clear ends those rows and removes the .partial residue a removal stranded
restoreask the storage provider to make one archived copy readable again; billed and slow, so --acknowledge is required
versionreport the binary, Go and embedded rclone versions

The README's copy of this table is checked against the binary's own dispatch table on every run of the repository's gate, so it cannot quietly go stale. The copy above is a transcription of it and is not checked, so if the two ever disagree, the README is right.

The reference page carries the same table with the subcommands and the interesting flags filled in, plus where a configuration change is written and what each exit code means. That copy is checked.

What has not been proven

This section exists because the README has one, and a documentation site that quietly drops the uncomfortable half is worse than no site.