Names and the mount

Apps that keep their own file records store files by key. Apps written for plain files get paths instead: a bucket per app, real directories, rename, and a FUSE mount that makes the bucket an ordinary directory for nginx, PHP, Twig, cp, tar and rsync. In Kubernetes, the CSI driver puts that mount into a pod.

Buckets

A name is <bucket>/<path>. The bucket is the app's name in WARRENFS_CLIENT_TOKENS: with shop=<token>, the shop token reaches shop/... and nothing else; any other bucket answers 403. One token per app means access can be revoked per app, and the logs show which app wrote or deleted what.

Paths are at most 4096 bytes, each element at most 255 bytes; empty elements, ., .. and NUL are refused.

Entries and shards

A name is an entry pointing at a stored file. The file itself is stored like any other (two copies, a volume, a key); the entry holds the key, size, SHA-256, content type, modification time and a version. Directories are entries of their own, so empty directories exist and mkdir works.

The name index is split into 64 shards, each a bbolt database, and every node holds a copy of every shard. An entry lives in the shard chosen by a hash of its parent directory, so:

  • a lookup never leaves the node;
  • listing a directory reads one shard;
  • names survive any number of node failures short of the last one.

Names are small and read far more often than changed, which is what makes a copy on every node cheap. A node that joins gets its shard copies filled like a repair.

Operations

Operation Behaviour
write (PUT) Stores the file, then points the name at it. Missing parent directories are created. Overwriting deletes the file the name pointed at before.
read (GET, HEAD) The file, with Range and conditional requests, as by key.
stat, list The entry; the entries directly under a directory, in name order, optionally only the first n.
mkdir With parents=1, like mkdir -p; without, a missing parent is 404.
remove (DELETE) A file, or an empty directory; a directory with entries is 409.
rename Within the bucket. The target's parent must exist. An existing target file is replaced; an existing target directory only when empty.
touch Sets the modification time.

A rename changes names, not files: the key stays the same. Renaming a directory moves its entries one by one, so it is not atomic: a failure halfway leaves part of the tree under each name, and running the same rename again finishes it.

Every change carries a version (the writer's clock, always above the version it replaces) and the newest wins. Deleted names leave tombstones that the shard copies pull from each other, like deleted files. The exact routes are in the HTTP API reference.

The FUSE mount

WARRENFS_TOKEN=<token> warrenfs mount --url http://<node IP>:7480 --bucket <bucket> /mnt/<bucket>

The mount talks to the WarrenFS instance on the same machine, by name for lookups and by key for reads.

What works

  • Reads, fast: a file up to 1 MiB is fetched whole on its first read; a larger one in ranges that grow from 128 KiB to 8 MiB while it is read sequentially. An inode stands for one key, and a key never changes, so the kernel keeps the file's pages across opens.
  • Whole-file writes: create or truncate, write, close. The bytes collect in a local spool file and are stored, with both copies, on close (or fsync). A failed store is returned by close, where programs that check for errors will see it.
  • Directories: mkdir, rmdir, rename (also with RENAME_NOREPLACE), unlink, listing.
  • Modification times: touch and utimes are kept.
  • In practice: >, cp, tar, rsync, PHP's file_put_contents, nginx X-Accel-Redirect and Twig template loading all work unchanged.

What costs more than it looks

  • Writing into the middle of an existing file works, but downloads the whole file first and stores it as a new file on close.

What is refused or ignored

Operation Result
appending (O_APPEND, >>) refused: Operation not supported
truncating a file to a size other than 0 without opening it refused: Operation not supported
symbolic and hard links not supported
chmod, chown accepted and ignored: files are 0644, directories 0755, owned by --uid and --gid
anything on a read-only mount Read-only file system

df on the mount shows a fixed 1 TiB: the space is the cluster's, not one disk's. The real numbers are in warrenfs status. Names and attributes are trusted for the cache timeout (1 second; the CSI driver's cacheTimeout), so another node's change shows up at most that much later.

If the local instance restarts, requests wait and retry for up to 30 seconds instead of failing.

The CSI driver

warrenfs csi is a CSI node plugin named warrenfs: no controller, inline ephemeral volumes only. A pod asks for a bucket directly in its spec, with the token in a Secret in its own namespace:

volumes:
  - name: data
    csi:
      driver: warrenfs
      volumeAttributes:
        bucket: shop
        uid: "1000"
        gid: "1000"
        cacheTimeout: 30s
      nodePublishSecretRef:
        name: warrenfs-shop
containers:
  - name: app
    volumeMounts:
      - name: data
        mountPath: /data
        mountPropagation: HostToContainer
Attribute Meaning
bucket required: the bucket, matching the token's app
uid, gid owner of every file and directory; default 0
cacheTimeout how long names and attributes are trusted, a Go duration; default 1s
readOnly "true" mounts read-only (as does the volume's own readOnly)
secret key token required: the app's client token

The plugin runs as its own DaemonSet, separate from the storage instances, so restarting or upgrading storage does not touch the mounts. When the plugin itself restarts it mounts every volume it served again; the pod must mount the volume with mountPropagation: HostToContainer for the new mount to reach it. Deploying the plugin is in Kubernetes.

What not to put on it

  • Files that change in place: databases, SQLite, logs, anything appended to. Use a local disk or a block volume.
  • Scratch space whose bytes need no second copy. It works, it just costs two disks and a network hop.

Files that are written whole and read many times are the sweet spot: media, uploads, themes and templates, generated derivatives, build artifacts. Image derivative caches and upload temp files fit too, when the app writes them whole: a derivative made on one node then serves every node.