Repository navigation
ZFS storage: per-tenant pools and the rollback-integrity fix - #3
Merged
Merged
Conversation
Everything the enclave keeps — each tenant's files and the runtime's own records (passkeys, tasks, streams, devices) — now lives on one ZFS pool, on a disk the parent serves over vsock, under plain dm-crypt with a key the parent never sees. s3fs, the copy-on-write filesystem written here, goes with its own wasi:filesystem host and the data bucket: guests get wasmtime-wasi's filesystem over their tenant's dataset, and what remained of s3fs-core (the S3 backend, keys, signing) moves into the runtime as `store`. ZFS cannot stop rollback when the host holds the disk, so after every write the pool is anchored: `zfs set enclave:anchor=<seq>-<nonce>` (which also syncs), the txg it reached and the nonce signed, chained and published to the Object-Locked roots bucket. A txg number alone cannot name a state — ZFS reuses the abandoned ones after a rewind, and their uberblocks stay on the disk — so boot imports as of the anchored txg (`zpool import -T`) and refuses unless the property it reads back is the newest anchor's. A rolled-back disk and an abandoned fork are refused; a crash between sync and publish rewinds. Nothing is acknowledged before its anchor: a response's end (a streamed body streams and only its end and trailers wait), a registration, a task's outcome. passkey-client now refuses a response cut off before it ended, which is what let the crash test pass a lost write. The kernel is built from source with AWS's own enclave recipe (6.6, their config and patches, including the vsock pushback fix block I/O over vsock needs) plus NBD, dm-crypt and OpenZFS; the build user and host are pinned so PCR0 does not depend on the builder. The kernel refuses a vsock socket for NBD, so it gets one end of a socketpair bridged to vsock. On Nitro the parent serves an EBS volume with stock nbdkit from the Amazon Linux 2023 repositories (nbdkit.service; tofu creates the gp3 volume); the emulator serves a disk image with deploy/qemu-nitro/nbd-stub.py. The S3FS_ prefix becomes ENCLAVE_, and the guest denylist with it; the images are enclave.eif and enclave-qemu.eif. tofu takes `roots_bucket` and `pool_size_gib` in place of `buckets`. Verified: ci-check.sh, the guest-backed suites, and on the QEMU emulator run-zfs-spike.sh (anchored writes, tenant isolation, resume, rollback refused, rewind, fork refused) and run-e2e.sh. Not yet on Nitro hardware: the EBS volume and nbdkit path are validated only by tofu, packer and systemd. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant id is now the first 16 bytes of sha256("enclave/tenant/v1" || credential id), computed
where it is needed, rather than drawn from the NSM at registration and stored in the credential
record. It was minted so a host could not predict a tenant's directory and create it first. On the
pool a host cannot create anything, and a guest can name nothing but its own dataset, so an
unpredictable name protected nothing. The `join` path that would have let a second passkey share a
tenant was never wired up, so behaviour is unchanged: one passkey, one dataset.
Credential records drop their tenant id and become version 2. A version-1 record is refused rather
than given a derived id, which would be a different directory, so pools made before this need
recreating. `tenant_of_is_pinned` fixes the derivation, because changing it strands every tenant's
dataset. Registering an existing credential id again now reaches that credential's own directory
before it is refused, instead of leaving an empty dataset behind.
Verified on this commit alone: fmt, clippy, the workspace unit tests, boot_origin, serve_auth and
harness.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant's warm instance is gone. Each request, task run and held-connection message now builds an instance over the tenant's dataset under the tenant's lock, and drops it when the call ends. Nothing a guest holds in memory survives a call, so background work no longer has to discard a cached HTTP instance before it changes the dataset, and each passkey approval runs in an instance no earlier request touched. The cosigner already reopened its seal on every call, so it loses nothing, and instantiating costs about 24 µs. The pool now holds only a lock per tenant. Deleted: LiveTenant, the rebuild counter and --max-requests-per-instance (ENCLAVE_MAX_REQUESTS_PER_INSTANCE), the resources_settled check, and the take-out and put-back handling for aborted calls. The tests and the emulator's e2e leg now expect /memory to answer 1 on every request. Verified: ci-check.sh, ci-guests.sh (every guest-backed suite) and harness. Not run: run-e2e.sh on the emulator. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
deploy/Dockerfile built the runtime without ZFS's tools and without a roots bucket, both of which the runtime has needed since the pool replaced s3fs; nothing built it. scripts/ci-sqlite.sh ran the runtime directly on the host, which cannot measure its guest without an NSM or reach a pool without the parent's disk; nothing ran it, and docs/STORAGE.md already shows SQLite running in the emulator. The tofu user data wrote ENCLAVE_ROOTS_BUCKET, AWS_REGION and ENCLAVE_TLS_DOMAINS into the parent's enclave.service, where nothing read them: nitro-cli passes no environment into an enclave, whose settings are in its image and measured into PCR0. They and the tls_domains variable that only fed them are gone; the pool's disk, which nbdkit does read, stays. Verified: tofu fmt and validate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every setting is baked into the image and measured into PCR0, so changing one already meant a new image, exactly as changing a constant does. A setting earns its place only where two deployments of the same code need different values. Of the 52 ENCLAVE_* settings, 12 pass that test: the roots bucket and prefix, the filesystem id, the KMS key and its parameter, root retention and the anchor floor, the TLS domains, the relying party and its allowed origins, the push application and the guest log group. With AWS_REGION, they are all a production binary reads; its --help lists nothing else. The emulator's alternatives — a static master key, unsigned receipts, the host clock, MinIO, Pebble, push and log stubs — become flags of the `testing` build alone, grouped in one `Testing` struct whose production values are written once in `Testing::production`. The static key and the unsigned-receipt mode are compiled out of the library as well, so a production binary contains no code that could take a key from configuration or accept an unsigned receipt. Everything else is a constant: KMS, PTP, the NSM, gvproxy, ACME on :443, the guest at guest/guest.wasm, the log stream `guest`, the origin https://<rp id>, the timeouts, TTLs and limits everyone ran with, and "production" in the KMS encryption context, which keeps keys sealed under it opening. `--tls off` goes with TLS: it could never start a server, since attestation is always on. Background tasks run when the guest exports run-task, rather than when a setting says so: the guest is measured into PCR16, so its exports already are the decision. A guest's environment is now exactly the settings its file carries; the runtime's own environment no longer reaches it, so the inheritance policy, its denylist and --guest-env/--no-inherit-env are gone. ptp-check.sh passes flags that exist again, and ci-check.sh now runs the binary's own tests, which `cargo test --lib` never did. Verified: ci-check.sh, the binary's tests, clippy on the production binary without features, ci-guests.sh, harness, nix build of eif and eif-qemu (the production image bakes exactly the 12 settings and AWS_REGION), and run-e2e.sh on the emulator, background tasks and device wakes included. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An anchor's nonce reaches the disk in one txg (the `zfs set` marker), and the
anchor names a later one (the second `zpool sync`). Another tenant's write
that lands between them is covered by that anchor and acknowledged through
anchor()'s `synced == prev.txg` shortcut. The disk as of the marker holds the
same nonce without that write, and `zpool import -T` loads the newest
uberblock at or below the anchored txg: the property and guid checks pass.
The testing build now stops at named points in an anchor (anchor-wait,
after-marker, after-sync, after-publish) and asks the host over vsock port
9101 whether to go on; deploy/qemu-nitro/test-hooks.py answers from a rules
file. This replaces ENCLAVE_ZFS_CRASH_BEFORE_ANCHOR, so the crash leg no
longer rebuilds the image. The parent always runs the listener: with nobody
listening each ask waits out vsock's 2 s connect timeout, which is what this
run's 6.4 s anchors in leg 3 were. After every import the testing build logs
the kernel's own load notes from dbgmsg and the first txgs it opened.
Leg 7 of run-zfs-spike.sh, on the emulator, reproduces it and fails:
alice's anchor held at after-marker, the disk copied, bob's write
acknowledged behind it; one anchor (seq 111, txg 846) covered both. Served
the copy, the boot logged `spa_load(enclave, config untrusted): using
uberblock with txg=845`, then `zfs pool resumed at its anchor seq=111
txg=846`, and bob's acked.txt was gone.
Leg 6 shows the rewind it relies on is not dependable: after a crash three
txgs past anchor 107 (txg 810), `-T 810` failed on txg 810 ("couldn't get
'config' value in MOS directory [error=5]", its blocks reused) and ZFS fell
back to 809, the anchor's marker. That boot passed only through the same flaw.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The anchor's nonce reaches the disk a txg or more before the txg the anchor
names, and a write landing between them is acknowledged by the anchor. Boot
imported with `zpool import -T <anchored txg>`, which takes the newest
uberblock at or below it, with no exact match, so the disk as of the marker
passed the nonce check without that write (leg 7, previous commit).
`-T` also falls back to older uberblocks when one fails to load. In leg 6's
honest crash, three txgs past the anchor, the anchored txg's blocks were
already reused and ZFS loaded the marker's txg instead. Requiring the exact
txg would have refused that disk for good, so there is no rewind any more:
- Boot imports the newest state on the disk, unmounted, from the exact
device and by guid, and reads which txg it loaded from the kernel's own
notes on that import (`spa_load(enclave, …): using uberblock with txg=N`
in dbgmsg, from the last load that ended LOADED).
- It admits the state only if that txg is no older than the anchored one
and its marker is the newest anchor's, or that of the next anchor (written,
never published) naming the newest as its predecessor. The marker is now
`<seq>-<nonce>-<previous anchor's hash>`; pools marked before still match.
- A crash leaves unacknowledged writes in place rather than rewinding them.
Also:
- anchor() runs in a task of its own, so a deadline, an abort or a dropped
/auth future cannot stop it between the marker and the publish.
- A response that may not have a body (HEAD, 1xx, 204, 304) is held until
its writes are anchored. hyper never polls such a body, so it went out
complete before the anchor on HTTP/1.1.
- STORAGE.md says what the design rests on: plain AES-XTS authenticates
nothing, ZFS checksums are not MACs, and fletcher4 metadata is protected
only by the host being unable to choose plaintext.
Validation, on this tree:
- scripts/ci-check.sh: pass (392 lib tests, bin, boot_origin).
- deploy/qemu-nitro/run-zfs-spike.sh: all 7 legs pass. The marker's disk is
refused ("it loaded txg 846, older than the anchored txg 847"), and the
crashed disk is admitted through the unpublished successor's marker.
POST p50 255 ms, GET p50 182 ms, anchor mean 77.6 ms.
- deploy/qemu-nitro/run-e2e.sh: all 8 legs pass.
- serve_h2 4/4, serve_grpc 12/12, serve_grpc_wire 3/3 with built guests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A second enclave the host starts while the first is mid-anchor can fork the first's disk and still let the first publish the anchor that confirms a write the fork has dropped. This closes the concurrent-writer half of that: the fork-after-publish half needs authenticated storage and stays open, reduced later by one writer per pool. Each boot, before the read-write import, claims the next free number in a contiguous chain of signed, Object-Locked `zfs/generations/<g>` records (claim_generation, racing other boots for each number). Every anchor, as late as it can before the publish that would acknowledge its writes, refuses if generation + 1 exists (seal_and_publish). So a superseded enclave cannot acknowledge anything: in the attack, the second enclave's boot claims the next generation before the first — frozen by the host mid-anchor — reaches its check, and the first then refuses and never confirms. The records are signed and verified so the host cannot fence an enclave out with a record forged for another filesystem, and retained-read like anchors so it cannot hide the newest. The anchor wire format is unchanged; the per-tenant StorageManager will bump it once to ZFSANCH2 and carry the same fence per pool. retained/exists/find_tip are generalised over a key so the generation chain reuses the anchor chain's gallop-then-bisect. Validation, on this tree: - cargo test zfs: generation codec round-trips and refuses tampering, a wrong number, another master and truncation; boots claim 0,1,2 in order against a shared backend; a mutant that stops advancing the claim is caught. - scripts/ci-check.sh: pass (394 lib tests, bin, boot_origin). - deploy/qemu-nitro/run-zfs-spike.sh: all 7 legs pass; the bucket holds one generation record per boot, consoles log the claimed number, and the per-anchor fence check adds ~36 ms (114 ms mean anchor, was 78 ms). The refuse path needs two concurrent enclaves against one bucket (two guest CIDs, two NBD stubs, a shared store) and is a stage-7 harness item; here it is covered by unit tests and the real-ZFS happy path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ahead of a pool per tenant: the pool name, its dm-crypt device and its
anchor-chain prefix become fields on `Zfs` (name, mapped, anchor_prefix)
instead of the `POOL`/`MAPPED` constants and a `prefix`-derived anchor key,
and `synced_txg`/`pool_guid`/`log_loaded_state` take the pool name. The
generation chain stays on the global `prefix`, since it is one per enclave,
not one per pool.
The values are unchanged — name `enclave`, device `/dev/mapper/zcrypt`,
anchor keys `{prefix}zfs/anchors/<seq>` — so this is behaviour-preserving;
`POOL`/`MAPPED` remain as the control pool's defaults in `attach`. A later
commit adds the manager that holds several of these and the regions they sit
on.
Validated by compile, clippy and the zfs unit tests; the substitutions are
value-identical to the constants, and the next real-ZFS spike (with the
region work) covers it end to end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-tenant layout needs an anchor to say which pool it speaks for, so the host cannot serve one pool's anchor for another. ZFSANCH2 adds, after the filesystem id: a pool kind (control or tenant), a 16-byte pool id, and a 16-byte tenant id, all signed. decode_and_verify now takes the expected pool id and refuses an anchor that carries a different one. The one pool today is the control pool: kind Control, a fixed sentinel pool id (`enclave-control!`), tenant id zero. Tenant pools, with random ids, come with the storage manager. The marker and admission logic are unchanged; only the signed body grew. This changes the anchor bytes and so the genesis hash the state-origin receipt commits to, so a v1 store will not resume under v2 — handled explicitly when the receipt is versioned (stage 6); every test and the spike start fresh. Validated by the zfs codec tests (round-trip carries kind/pool_id/tenant_id, tamper/replay/foreign-key/foreign-pool-id all refused) and the 14 boot_origin machine tests, which drive genesis -> resume -> receipt over v2 anchors on the memory backend. The real-ZFS mechanics are format-agnostic; the next spike (with the regions) exercises v2 on ZFS end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Splits the one type into a per-pool `Pool` (the former `Zfs`: device, anchor chain, last-anchor lock) and a new public `Zfs` manager that owns the control pool and will own a pool per tenant. The rest of the runtime holds the manager, as before. `anchor()` is replaced at the call sites by where the write went: anchor_tenant(tenant) for a request, a message or a task outcome; anchor_control() for a credential. The manager routes both to the one control pool for now — a tenant's writes still live in the control pool's /tenants — so this is behaviour-preserving. The next commit gives each tenant its own pool on its own disk region, at which point anchor_tenant anchors that pool and tenant_dir returns a path in it. Behaviour-preserving: compile, clippy (both feature sets), fmt, and all 394 lib tests. No on-disk or real-ZFS change, so no spike needed; the spike runs with the region work next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each tenant's data now lives on a ZFS pool of its own, so one tenant's anchor can never cover another's write — the same-nonce cross-tenant steal the single pool was prone to is gone by construction, not by the admission check alone. The one disk is cut into equal, 4 KiB-aligned regions (region_bounds). Region 0 is the control pool, which holds the runtime's records and a catalog; each tenant is allocated one of the rest on first use. A region is mapped as its own dm-crypt device with a domain-separated key, HKDF(master, fs_uuid, "zfs/dmcrypt/v2" | kind | pool_id), and carries one pool with a single `data` dataset — the guest's only preopen. `Zfs` is now a storage manager over a per-pool `Pool`: it brings up the disk once, claims the boot generation once, creates/opens the control pool, and imports a tenant's pool on demand (a `OnceCell` slot per tenant serialises its first use without serialising others; a create lock keeps two new tenants off one region). The catalog (`/runtime/catalog/<tenant>`: pool id, region, genesis hash) is on the control pool, validated at boot (regions distinct, in range, not the control pool's; pool ids distinct), so the host cannot point a tenant at another's pool. A pool id is random and minted once, so a tenant's history cannot be restarted under a guessed id. Each pool anchors on its own chain (`zfs/control/anchors/`, `zfs/pools/<id>/anchors/`) with its own last-anchor lock, so two tenants anchor independently. A request anchors its tenant pool and, for any record it left there, the control pool. v2 anchors (previous commit) bind each to its pool, and every pool is verified on import exactly as the boot verifies control, so a rolled-back or forked tenant disk is refused when that tenant is next used. Records stay on the control pool for now; moving them into each tenant's pool, and the operation boundary that drops the control anchor from a pure-data request, come next. Registration is bounded: a tenant that fills its region gets ENOSPC, and the disk filling up refuses new registrations (`--region-mib`, 200 MiB default). No tenant-pool eviction yet; pools are kept imported, capped by the region count. Validated on the emulator, deploy/qemu-nitro/run-zfs-spike.sh, all 7 legs: two tenants write to two pools (two anchor namespaces in S3) and cannot reach each other; POST p50 255 ms / GET 188 ms / anchor 93 ms, no regression; a restart resumes control and imports a tenant pool; a rolled-back disk, an abandoned fork, and a crash are each handled by the control pool's admission (loaded txg vs anchored, marker chain); and bob anchors while alice is held mid-anchor — tenants anchor independently. Plus scripts/ci-check.sh: fmt, clippy, 396 lib tests (region geometry, two tenants get independent pools, catalog validation), bin, boot_origin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The module-doc stack diagram, the anchor_prefix and mapped field docs still described the single pool. They now describe the control pool, the per-tenant pools, and their per-region dm-crypt devices and anchor prefixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tenant's background work now lives on the tenant's own pool, not the shared control pool: its tasks and streams go in a `runtime` dataset beside `data`, out of the guest's reach (only `data` is preopened). Devices and credentials stay on the control pool, which the forwarder and the pre-auth lookup need to see across all tenants. So the boot need not import every pool to find the work that is due, the catalog carries a `background` flag per tenant. It is set (durable on the control pool) before a tenant's first task or stream record, so a crash never leaves a record the scan would miss — a flag with no record only costs a spurious scan. At boot the task queue and stream registry import and scan only the flagged tenants; `mark_background` flips the flag, `tenant_runtime_dir` gives a tenant its own records directory, and `background_tenants` lists them. The `runtime` dataset carries a 16M reservation so a guest that fills `data` cannot stop a task being recorded. Also, from review: drop the dead `legacy` 2-field marker branch in `admit` (the ZFS layout never shipped, so no pool carries the pre-predecessor marker), and bound the spike's crash-leg client so an aborted enclave cannot wedge the run. Validated on the emulator: run-e2e.sh 8/8 — a background task is enqueued to the tenant's own pool, runs, wakes its owner, and (leg 7) survives a reboot, which exercises the background flag and the boot scan reloading it from that pool. run-zfs-spike.sh legs 1-5 pass with the data+runtime layout (two tenants in separate pools, isolation, bench unchanged, resume, rollback refused). Plus scripts/ci-check.sh: fmt, clippy, 396 lib tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…P time Every `retain_until` was computed from `std::time::SystemTime::now()`, the host-controlled system clock. A host that wound its clock back could shorten the Object Lock on an anchor, a generation record, the sealed key, the state-origin receipt or a pair record — making them deletable before their retention was really up, the rollback the lock exists to prevent. The boot's receipt and pair verification took "now" from the same host clock too. All of it now comes from the enclave's trusted clock — PTP (`/dev/ptp0`, Amazon Time Sync) on Nitro — the same `TrustedClock` the guest's wall time already uses. `open_clock` and `WallClockAdapter` now yield an `Arc<dyn TrustedClock>` so the one trusted clock is shared; `boot` takes it and threads it to `Zfs::create`/`open`, and `Pool`/the generation claim compute `retain_until` from `clock.now()`. `boot`'s `locked`, `open_receipt`, `verify_origin` and `record_pair` take a trusted `now`. What was already right and is unchanged: background tasks schedule off the `WallClockAdapter` (trusted); token and challenge TTLs, request timeouts, tenant-pool eviction and wake backoff use monotonic `Instant`, which a host cannot wind back. What stays on the host clock is telemetry only — CloudWatch log timestamps, S3 `last_modified` metadata — and the `clock_check` diagnostic; none bears on integrity. Validated: compile, clippy (both feature sets), fmt, 396 lib tests, and the 14 boot_origin machine tests, which drive genesis -> lock -> verify -> resume -> record-pair over the trusted clock. The emulator runs the host clock (ENCLAVE_CLOCK_SOURCE=host), so this is behaviour-neutral there; the point is wiring integrity-bearing time to the trusted source on Nitro. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…task begin Two ack-ordering fixes, so nothing is announced before it is durable. A `wake` the guest raises is now staged, not sent: the `Notifier` holds it per tenant (stage/deliver_staged/drop_staged), and each invocation delivers its staged wakes once its writes are anchored — the request path in the spawned anchor task, the message path after its anchor, the background task after `finish` anchors the outcome — or drops them if the anchor fails. A wake tells an owner "come and look", so sending one before the change is durable could wake them for a state a crash then rewinds. A tenant is serialised against itself, so one invocation stages and delivers before the next. A task's `begin` now anchors the attempt before the guest runs. Without it the Running record rewound with a crash, the attempt count never advanced, and a task that brought the enclave down looped on it for ever instead of failing at MAX_ATTEMPTS. Not done, and documented in STORAGE.md as the remaining piece of the operation boundary: a stream dial or send still goes out during the invocation, before its record is anchored. It carries no committed state (an attested request the far side deduplicates; a pre-anchor crash rewinds the record and the supervisor re-establishes or drops it), and deferring it means reworking the reconnect supervisor. Validated: run-e2e.sh 8/8 on the emulator — leg 6b confirms a finished task still wakes its owner, now only after `finish` anchors; plus scripts/ci-check.sh (fmt, clippy both feature sets, 397 lib tests incl. a new staging test, bin, boot_origin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The state-origin receipt now names `zfs/state-origin/v2` — the per-tenant pool
layout — so it cannot be read under a runtime that means the single-pool v1 by
it. On resume, before it claims a generation or imports anything, the runtime
checks for a v1 store (anchors under `{prefix}zfs/`, none under
`{prefix}zfs/control/`) and refuses it with a message pointing at the migration
procedure, rather than the opaque "no anchors" a v2 import would give. A v1
store is never resumed or reformatted in place — and never could be, since it
still holds its sealed key, so the boot takes resume, not genesis.
docs/COMPATIBILITY.md gains the version table and the offline migration
procedure (keep the v1 volume and prefix, bring up v2 fresh on new ones, retire
the old only once v2 is verified). docs/STORAGE.md records why the 200 MiB
region size is fixed for a store's life and not a runtime flag: a region's disk
offset is its index times its size, so changing it would move every tenant pool
out from under its dm-crypt mapping — to make it configurable it must be fixed
at genesis, committed in the anchored control state, and checked on resume.
Validated: scripts/ci-check.sh (fmt, clippy both feature sets, 398 lib tests
incl. a new test that a v1 store is refused before a generation is claimed,
bin, 14 boot_origin tests driving genesis/resume/verify over the v2 schema).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the ZFS storage branch to
main. Two headline pieces, both validated on real ZFS in the QEMU emulator (run-zfs-spike.sh7/7,run-e2e.sh8/8) and byscripts/ci-check.sh(fmt, clippy, 398 lib tests, bin, boot_origin).Rollback integrity
-T), and never rewinds.A pool per tenant
backgroundflag.wakeis held until its invocation's writes are durable; a task's attempt is anchored before it runs.zfs/state-origin/v2; a v1 store is refused with a migration message, never resumed or reformatted. Seedocs/STORAGE.mdanddocs/COMPATIBILITY.md.Known, documented residuals
🤖 Generated with Claude Code