This release is a pre-release and may not be stable for production use.
ferry
An edge agent that ships instrument data from watched folders to one or more S3-compatible stores -- AWS S3, Ceph RGW, SeaweedFS, MinIO -- on a cron schedule or as it lands, and tells your catalog about every acquisition.
Lab instruments write acquisitions to a local disk. ferry runs on the instrument PC (Windows, Linux or macOS) as one small binary. Each pipeline watches a folder, groups its files into units (one acquisition each), uploads them once they have finished being written -- to every destination the pipeline names, from a single read -- verifies the copies, hands each complete unit to your registrars (any command; the event arrives as JSON on stdin), and then keeps, moves or deletes the local copies. A local SQLite journal makes every step resumable, and every action is logged locally as JSON lines.
ferry is for write-once data: acquisitions that land, settle and are then left alone -- from instruments, sensors, cameras and the like. It is not a backup, sync or mirror tool for folders whose files keep changing, and it never deletes from a store.
Status: alpha (
0.1.0-alpha.1). The command isferry; the crate and the PyPI distribution areferry-ingest.
Install
uv tool install ferry-ingest # or: pip install ferry-ingest (a prebuilt binary wheel)
cargo install --locked --git https://github.com/Rosebud-Biosciences/ferry # or build it from source
Each GitHub release
also has a standalone ferry per platform -- the binary its wheel holds --
and SHA256SUMS. Every file carries a build provenance attestation: gh attestation verify FILE -R Rosebud-Biosciences/ferry shows that this
repository's release workflow built it.
Quickstart
# ferry.yaml
state_dir: ./state
stores:
ceph:
url: s3://instrument-raw/
endpoint: https://rgw.lab.internal
region: default
options:
aws_access_key_id: ${FERRY_S3_KEY}
aws_secret_access_key: ${FERRY_S3_SECRET}
archive:
url: s3://lab-archive/
region: us-west-2
pipelines:
- name: cytation
root: C:/Data/Cytation5
prefix: workcell/raw
destinations: [ceph, {store: archive, verify: sha256}]
unit: {dirs: "*__*"} # each matching top-level folder is one acquisition
include: ["*.tif"]
mode: cron
cron: "0 2 * * *"
finalize: {action: delete, after: 1d}
registrars: [manifest]
ferry config validate # parse and check it; show where data goes
ferry doctor --write # can this node reach its stores and hooks?
ferry run --dry-run # what a pass would upload, register and finalize
ferry run # one pass over every pipeline, then exit
ferry watch # run until stopped (cron and stream pipelines)
ferry service install # start `ferry watch` at boot, restart on failure
ferry status # units by state, backlog, failures
examples/ferry.example.yaml annotates every
option.
Pipelines and destinations
A config has any number of pipelines, each with its own root, schedule and
rules; their roots must not overlap. Each pipeline sends its files to one or
more destinations -- stores, each with an optional prefix, and:
verify: size(default): a HEAD after the upload confirms the stored size.verify: sha256also reads the object back and compares its SHA-256 with the one computed while uploading.required: true(default): registration waits for this destination. An optional destination is retried until it has the files, but a unit is registered without it.
With fanout: shared (default) a file is read once and every chunk goes to
every destination that needs it: the chunks are reference-counted, so a second
destination costs no extra disk reads or copies. A destination that fails
mid-file drops out and is retried later with its own read; the slowest
destination paces the rest. fanout: separate gives every destination its own
read instead.
When files travel
Each pipeline picks when a pass runs and what a pass uploads:
mode: cronruns a pass on a cron expression (cron: "0 2 * * *", read intimezone:or the node's local time) insideferry watch. A pass missed while the PC slept runs once on wake.mode: streamwatches continuously: OS file events (watch: events) are hints that trigger a rescan, backed by polling everypoll_intervaland a full walk everyfull_scan_interval. Usewatch: pollfor network shares, which deliver no reliable events.upload: fileuploads each file as soon as it settles.upload: unit(default) waits until the whole unit is complete, then uploads it.
Either way a unit is registered only once it is complete:
- A file has settled once its mtime is at least
settleold. Withprobe_exclusive: true(Windows), a file another process holds open has not. - A unit is complete once all its files have settled and either a
markerfile is present (images/index.xml,run.done, ...) or nothing in the unit has changed forunit_idle-- files ferry does not upload and directory mtimes included. ferry complete PIPELINE UNITends the wait by hand; a unit open formax_openraises an alert.
ferry run ignores mode and does one pass over every pipeline, for OS
schedulers and testing.
What ferry guarantees
- A copy counts only if the file did not change while it was read (it is stat'd before and after) and the stored object verified. A file whose size or mtime changes later is sent again. ferry tells versions of a file apart by size and mtime; a rewrite that keeps both is caught before a delete, which hashes the file first, and not otherwise.
- ferry acts only on what it could read. If a scan cannot read part of a unit
-- a permission, a share that drops, a name that is not valid Unicode, a
symbolic link (links are never followed) -- the unit waits: it does not
complete, register or finalize, and nothing the scan missed counts as gone.
An alert says what could not be read;
excludeit if it should not travel. - A unit is registered once it is complete and every required destination
holds a verified copy of every file -- never with a partial file list. Files
that change or appear later reopen the unit and register it again, with a
higher
generation. - Source files are kept, moved or deleted (
finalize.action: keep | move | delete) only once every destination holds a verified copy, every required registrar has confirmed the unit, and thefinalize.aftergrace period (if any) has passed. Before a move or delete, ferry then:- checks that every copy is still there with the size sent and the ETag recorded when it was verified; one that went missing or was overwritten is sent again first;
- sets each file aside by renaming it (
name.ferry-finalizing), so a writer that comes back makes a new file instead of changing the bytes in flight; - checks the file set aside is the one uploaded -- the same size and mtime, and before a delete the same SHA-256 -- and otherwise puts it back and sends it again;
- moves it (hashing it on the way if the backup is on another file system) or deletes it, and records that only while the journal still describes those bytes.
- Copies are tied to where a destination points: its bucket, endpoint and
prefix, or its directory. Change that, and ferry stops until you say what
happened (see
ferry destinationbelow). Strengtheningverifyfromsizetosha256sends again what the weaker check covered. - Every step is journaled, so a crash, reboot or killed process resumes where it stopped -- a move or delete interrupted after the rename included.
- A lost journal means uploading again, unless the pipeline sets
adopt.adopt: sha256reads back each object already at a destination and counts it if it hashes like the local file.adopt: sizecounts any object with the right key and size, so it is allowed only withfinalize.action: keep.
Stores
url: s3://bucket/prefix uses the object_store crate's S3 client: files up
to limits.single_put_max (64 MiB) go up in one PUT, bigger ones stream as
multipart uploads (part_size, part_concurrency), and every file's SHA-256 is
computed on the way and recorded. options passes any object_store S3
setting by name; credentials otherwise come from profile (static keys in
~/.aws/credentials) or the usual AWS_* environment variables.
bandwidth: 50MB caps a store's rate, optionally per local time window
({"08:00-18:00": 50MB}). A local or mounted directory (url: /mnt/nas/raw)
also works.
A file goes to <prefix>/<unit>/<path within the unit>, its names as the file
system gives them (no Unicode normalization), each percent-encoded only where a
key could not hold it faithfully: %, \ and control characters. So a\b.tif
(a legal name on Linux) and a/b.tif stay distinct. No two destinations, and
not log shipping, may write under the same prefix of one bucket or directory.
For Ceph RGW (and other self-hosted S3):
- Set
endpoint. Path-style addressing is the default, which is what RGW expects. Setregionto the zonegroup name if your RGW checks it. - Every request payload is signed (SigV4), so corruption in transit is
rejected.
checksum: sha256asks the server to verify a whole-object checksum as well; only enable it where the server validates it (RGW does from Tentacle).verify: sha256checks end to end regardless. - Give each node its own RGW user, with a bucket policy allowing
PutObject,GetObject/HeadObjectandListBucketon its prefixes. ferry never deletes from a store. - Add a lifecycle rule that aborts incomplete multipart uploads after a day: a killed process leaves its in-flight upload behind.
Registrars and notifiers
Registrars record complete units. An exec registrar is any command:
ferry writes a versioned JSON event (register, finalize, failures) to its
stdin, and exit code 0 means it was handled. Events go through a durable outbox
-- a catalog that is down delays registration but not uploads, delivery is
at-least-once and in order per unit, and nothing is finalized until every
required registrar has confirmed. The built-in manifest registrar writes
<unit>/_ferry_manifest.json (every file's size, SHA-256 and copies) next to
the data.
Notifiers receive alerts: failures that keep recurring, units stuck open,
and each pass's digest (as info, or warning if anything failed). With
events: [receipt], a notifier gets one receipt per unit once it is delivered
instead: where each copy is, how it was verified, and whether the source files
were kept, moved or deleted. Receipts wait in the outbox until they are taken.
webhook: URL posts Slack-compatible text (or, with format: event, the event
JSON); exec: runs a command with the event on stdin.
docs/hooks.md specifies the events, with an example hook.
Operating it
ferry doctorchecks that a node can run -- its config, state and log directories, the journal (a destination that moved, events waiting for a registrar that is gone), each root, every store and every hook's program -- and exits 1 on anything that would stop ferry.--writealso puts a probe object in each store (_ferry/doctor/<node>) and reads it back;--notifysends each notifier a test message.ferry status [--units] [--json]shows units by state, the upload backlog, and every failing copy or delivery with its last error.metrics: {listen: 127.0.0.1:9187}(orferry watch --metrics ADDR) serves the same numbers to Prometheus at/metrics-- units by state, the backlog, failing copies and deliveries, each pipeline's last pass, and transfers and bytes by destination and outcome -- and/healthz, which answers 200 while the journal does. Neither asks for credentials: keep them on localhost or a management network. A node nothing can scrape can push a heartbeat instead (logs.ship.heartbeat).ferry retry [-p PIPELINE] [-u UNIT]clears retry backoff (and re-opens an abandoned unit). Failures otherwise retry on their own with exponential backoff (retry.initial,retry.max), and alert afterretry.alert_afterattempts.ferry abandon PIPELINE UNITstops tracking a unit; its files stay where they are.ferry destination accept PIPELINE DESTINATION-- a destination's new settings reach the same objects, so its copies keep counting -- orferry destination reset PIPELINE DESTINATION-- they do not, so every file not yet finalized goes up again. ferry refuses to run until one is chosen; both take--dry-run.ferry outbox discard PIPELINE REGISTRAR [--dry-run]drops the undelivered events of a registrar that was renamed or removed. ferry refuses to run while any wait for one, rather than finalizing units it never registered.- Logs go to stderr and to
<state_dir>/logs/ferry.YYYY-MM-DD.jsonl(UTC days), one JSON object per line;FERRY_LOG=debug(anytracingfilter) overrideslogs.level. Withlogs.ship, finished days are copied to a store,logs.ship.registrarsare told about each (and told again on the next round if they failed), and a heartbeat object with every pipeline's summary is refreshed. ferry service installregisters a native Windows service (delayed automatic start, restarted on failure; run it as administrator, and pass--accountto run as a user that can reach network shares), a systemd unit (--systemfor a system unit), or a launchd job on macOS (--system, with sudo, for a daemon that starts at boot; otherwise an agent that starts when you log in).--accountruns a--systemservice as that user.ferry service printshows the definition without installing it.- Keep a service's secrets in an env file, not in the config or the machine's
environment:
KEY=VALUElines that--env-file PATH(orFERRY_ENV_FILE) sets before the config loads, so its${VAR}s, theAWS_*variables and hooks all see them.ferry --env-file PATH service installmakes the service read it too. Only the account the service runs as should be able to read the file:- Linux and macOS:
chmod 600 ferry.env, owned by that account (root for a system service); ferry warns about a file others can read. - Windows:
icacls C:\ProgramData\ferry\ferry.env /inheritance:r /grant:r SYSTEM:R Administrators:F(and grant the--account, if one is set).
- Linux and macOS:
Compatibility
Until 1.0, a minor release (0.x) may break any of the following, and the
changelog says how; from 1.0, only a major release does. What
releases keep stable:
- The command line: commands, flags and exit codes (0 success; 1 a pass with failures, which are retried, or an operator command that could not act; 2 an error, such as a config that does not load).
- The config format, version 1 (
version: 1, optional). A config written for a newer format is refused, never misread. - What hooks receive: events (
ferry.event/v1) and manifests (ferry.manifest/v1), with JSON Schemas inschemas/. Fields and event kinds are added without changingschema; a change that would break a consumer that ignores what it does not know changes it. - The journal: a newer ferry upgrades it in place the first time it opens it, and an older ferry refuses a journal a newer one upgraded.
The Rust library is ferry's internals, not an API, and is not published to crates.io.
Development
cargo fmt --check && cargo clippy --all-targets -- -D warnings
cargo test
FERRY_BLESS=1 cargo test --lib schemas regenerates schemas/ after a change
to what hooks receive.
The S3 tests run when FERRY_TEST_S3_ENDPOINT (plus FERRY_TEST_S3_KEY,
FERRY_TEST_S3_SECRET and an existing FERRY_TEST_S3_BUCKET) points at an S3
API, as CI does with SeaweedFS. docs/design.md covers the
architecture and the reasoning behind it.
Contributions are welcome: CONTRIBUTING.md says how, and taking part means following the Code of Conduct. Report security problems privately, as SECURITY.md describes.
License
Apache-2.0. See LICENSE.
Metadata
Release files for ferry-ingest 0.1.0a1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ferry_ingest-0.1.0a1.tar.gz | 197.5 kB | With provenance | Details for ferry_ingest-0.1.0a1.tar.gz |
Built distributions (wheels)
Total release size: 41.7 MB
Release files / ferry_ingest-0.1.0a1.tar.gz
| Download URL | ferry_ingest-0.1.0a1.tar.gz |
|---|---|
| Size | 197.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f89be742496cafd166a1213f06838a2259cbeb364bf336cb20db3f56c62f0e48
|
|
BLAKE2b-256 checksum How to use checksums |
ed661acda6b5522945f58a6bc0fa144f1d05988e9eef1c946128e08de15e184b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / ferry_ingest-0.1.0a1-py3-none-win_arm64.whl
| Download URL | ferry_ingest-0.1.0a1-py3-none-win_arm64.whl |
|---|---|
| Size | 6.8 MB |
| Tags | Python 3 Windows ARM64 |
|
SHA-256 checksum How to use checksums |
bee93bc2abde420bfe20de24f07dd02945e10a71aaf4f55e2ac7219c80a9bb84
|
|
BLAKE2b-256 checksum How to use checksums |
6f0a820c837d34967278bb1d73ecd04959d168d10abd1d8e26b362d14eebbb3d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / ferry_ingest-0.1.0a1-py3-none-win_amd64.whl
| Download URL | ferry_ingest-0.1.0a1-py3-none-win_amd64.whl |
|---|---|
| Size | 7.2 MB |
| Tags | Python 3 Windows x86-64 |
|
SHA-256 checksum How to use checksums |
ce27de9586f660068e0fb777f33b819817331161c1a5bb99118cb895445dfad2
|
|
BLAKE2b-256 checksum How to use checksums |
ab62c5e57ffc2d70614517274e424511ed4f2eaf031f77114ab4a2347b1732ea
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / ferry_ingest-0.1.0a1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | ferry_ingest-0.1.0a1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 7.3 MB |
| Tags | Linux glibc 2.17+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
d8ac5367f707854aa5d1d60b78553f3d256300b944e849cf5153d9c431252764
|
|
BLAKE2b-256 checksum How to use checksums |
b9270d7eaad9c8f8debb1ca2d86f8db24033ad37302e6a2cfe65f32d1203f169
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / ferry_ingest-0.1.0a1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | ferry_ingest-0.1.0a1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 6.9 MB |
| Tags | Linux glibc 2.17+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
fc715aaf9c3800e6e7bf689a5fbc9799ab2100156d5bda1b699555521da2b98f
|
|
BLAKE2b-256 checksum How to use checksums |
d1969ebf74f7cbe570b5bd54e4cc8ab4948f237b7aafdf50b88924fec6eca8ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / ferry_ingest-0.1.0a1-py3-none-macosx_11_0_arm64.whl
| Download URL | ferry_ingest-0.1.0a1-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 6.2 MB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
e483c7802ee5f533f10a139665282b117090fb168308f760528b7ebf0b0aeae3
|
|
BLAKE2b-256 checksum How to use checksums |
6130c4c1ccb374c63c225cdeb3d462b495e2a6918700fc3d624cfc41014d90a8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / ferry_ingest-0.1.0a1-py3-none-macosx_10_12_x86_64.whl
| Download URL | ferry_ingest-0.1.0a1-py3-none-macosx_10_12_x86_64.whl |
|---|---|
| Size | 7.1 MB |
| Tags | Python 3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
f2a5f7e65de7b11d1bfb11d5753ecbfd85cd1fcae6db967ced3d21bb6258b9b8
|
|
BLAKE2b-256 checksum How to use checksums |
84128a474a5be35f44c0593e0e2abd224ba577d16cd074a186db81aa0656ced1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logSecurity for ferry-ingest 0.1.0a1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 7 files in this release have been uploaded with provenance metadata.
Attestation for ferry_ingest-0.1.0a1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.