From 0fc43ef6d0ec1cd2301eab4b2a4b6add94bf5a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 21:59:49 +0000 Subject: [PATCH] plan: filesystem quotas, the half the platform cannot do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-user and per-team storage limits need the kernel, not the platform. Members hold terminal at write and have their own Linux accounts, so a quota counted through the file browser is decorative — they open a shell and write past it. The plan is usrquota and grpquota, deliberately not project quotas: every case here is "this user" or "this team", both already identities the kernel knows, and a team is already a Linux group. Skipping prjquota also skips the inode-size trap, since project quotas need 256-byte inodes and cannot be added to a filesystem that has 128. Includes the pre-flight that decides whether the procedure applies at all, the rescue-boot steps, and a throwaway-account test that proves enforcement — because enabling quotas and setting none looks exactly like success. The easiest thing to get wrong is recorded prominently: a team folder that is not setgid charges its files to each member's own group, so the team limit counts nothing. For edge-pertento, which is the first real multi-user host and already has one account. officer-dev will not be multi-user and alpha is the owner's own; neither needs it. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- CLAUDE.md | 4 ++ docs/filesystem-quotas.md | 137 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 docs/filesystem-quotas.md diff --git a/CLAUDE.md b/CLAUDE.md index d946be26..a2da7d4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,6 +537,10 @@ explaining why it was safe. - `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing anything it already rules out +- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage + limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write + past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to + get wrong — a team folder that is not `setgid` charges nothing to the team - `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree** - `src/apps/CLAUDE.md` — shared frontend patterns - `src/databases/CLAUDE.md` — database patterns diff --git a/docs/filesystem-quotas.md b/docs/filesystem-quotas.md new file mode 100644 index 00000000..03e652d2 --- /dev/null +++ b/docs/filesystem-quotas.md @@ -0,0 +1,137 @@ +# Filesystem quotas — the prerequisite for per-user and per-team storage limits + +**Status: planned, not done. Nothing in the platform reads or writes a quota yet.** + +This is the host-side half. It makes limits *enforceable*; it does not create any. The platform side — +the owner declaring defaults and maxima, and provisioning applying them — is `docs/roles-and-teams.md` +→ "Quotas", and it is not built either. Do this first, because a policy nothing enforces is worse than +no policy: it reads as a limit and is not one. + +## Why the kernel and not the platform + +Members hold `terminal` at `write` and have their own Linux accounts. A quota the platform tracks by +counting bytes through the file browser is decorative — a member opens a terminal, or connects over SSH +with their own key, and writes past it without the platform seeing the write. + +Only the filesystem can refuse. So this is a provisioning feature, not an accounting one. + +## Where it matters + +**`edge-pertento`.** It is the first real multi-user deployment and the only host that will hold accounts +that are not the owner's. It already has one account, which is the argument for doing this soon rather +than carefully later: moving a home directory is easy once and tedious per person. + +`officer-dev` is a development machine and will not be multi-user. `alpha` is the owner's own. Neither +needs this, and neither can do it without the same downtime, so there is no reason to. + +## What to check BEFORE booting into rescue + +Run these on the live machine. They decide whether the procedure below is the right one. + +```bash +findmnt -no FSTYPE,SOURCE,TARGET / # expect ext4, and note the real device +findmnt -no OPTIONS / | tr ',' '\n' | grep -i quota # expect nothing — that is why we are here +sudo tune2fs -l "$(findmnt -no SOURCE /)" | grep -iE 'features|Inode size' +lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT +df -h /home +``` + +Three things decide the plan: + +1. **Is `/home` its own filesystem, or part of `/`?** On a stock Hetzner image it is part of `/`, so the + root filesystem is what gains the feature. +2. **Inode size.** Only matters if you use project quotas — they need 256 bytes and cannot be enabled on a + 128-byte filesystem without recreating it. The plan below avoids project quotas for exactly this reason. +3. **Is the device under LVM?** `alpha` is; `officer-dev` is not. It changes only the device path. + +## The plan: user quotas and group quotas, not project quotas + +Two mount options cover both cases and neither needs a feature the filesystem might lack: + +- **`usrquota`** — a limit per Linux user. This is the per-account home directory limit. +- **`grpquota`** — a limit per Linux group. **This is how a team's shared folder gets a limit**, because a + team is already a Linux group (see `docs/roles-and-teams.md` — the group is what keeps one team out of + another's folder, not the permission grant). + +Project quotas (`prjquota`) would let a *directory* carry a limit independent of ownership. That is more +flexible and it is not needed: every case here is "this user" or "this team", both of which are already +identities the kernel knows. Skipping it also skips the inode-size problem entirely. + +**For the group quota to work, the team folder must be `setgid`** (`chmod g+s`), so files created inside it +inherit the team's group and are charged to it. Without that, a member's files are charged to the member's +own primary group and the team limit counts nothing. This is the single easiest thing to get wrong here. + +## Procedure + +Downtime is roughly ten minutes, most of it reboots. The owner has said downtime is acceptable. + +**1. Install the tools, on the live machine.** + +```bash +sudo apt-get update && sudo apt-get install -y quota +``` + +**2. Take note of the root device**, from the pre-flight above — `/dev/sda1` on a stock image, +`/dev/mapper/-` under LVM. Every command below uses it. + +**3. Boot into Hetzner rescue** from the Cloud console, and do not mount the filesystem. + +**4. Check it, then add the feature.** `tune2fs` refuses on a filesystem that is not clean, and the check +is worth running anyway before changing metadata: + +```bash +e2fsck -f /dev/sda1 +tune2fs -O quota /dev/sda1 +tune2fs -l /dev/sda1 | grep -i features # 'quota' must now appear +``` + +`quota` here is the ext4 *journaled quota* feature. It is what lets quotas come up with the filesystem +rather than needing a `quotacheck` scan at every boot, which on 320 GB is not quick. + +**5. Add the mount options.** Back on the normal system — or by mounting the root filesystem from rescue +and editing it there — the `/` line in `/etc/fstab` gains `usrquota,grpquota`: + +``` +UUID= / ext4 defaults,usrquota,grpquota 0 1 +``` + +**6. Reboot into the normal system and confirm.** + +```bash +findmnt -no OPTIONS / | tr ',' '\n' | grep quota # usrquota, grpquota +sudo quotaon -p / # both should report "is on" +sudo repquota -s / # a table, not an error +``` + +## Verify it actually refuses + +Enabling quotas and setting none is the failure that looks like success. Prove enforcement once, on a +throwaway account, before trusting it: + +```bash +sudo useradd -m quotaprobe +sudo setquota -u quotaprobe 10240 10240 0 0 / # 10 MB soft and hard +sudo -u quotaprobe dd if=/dev/zero of=/home/quotaprobe/fill bs=1M count=50 +# expect: "Disk quota exceeded" and a file of about 10 MB, NOT 50 +sudo userdel -r quotaprobe +``` + +If that writes 50 MB, quotas are not on, whatever the mount options say. + +## What this does not do + +- **It sets no limits.** Every existing account is unlimited until something assigns a quota. That is the + correct intermediate state: the machine gains the ability, nobody's behaviour changes. +- **It does not touch the platform.** `provision-os.ts` does not call `setquota` yet, and there is nowhere + for the owner to declare a default. That is the other half. +- **It does not apply to the team folders**, because there are no team folders yet. When they arrive they + must be `setgid` or their group quota counts nothing. + +## When the platform half is built + +`POST /users/:id/provision-linux` already exists as a retry path and is the natural home for this: it +becomes "re-apply current policy to this account", covering both a new account and a changed limit. + +**On a host where quotas are not enabled, the platform must say so** — "policy recorded, not enforceable on +this host" — rather than storing numbers that do nothing. Same rule as the plugin dependency check: refuse +or report, never silently no-op. `quotaon -p` is the check, and it is cheap enough to run per request.