diff --git a/CLAUDE.md b/CLAUDE.md index 9fae39b..e234f52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ When debugging container behavior, the order is: image `/etc/{rc,fstab,environme ## Active design proposals - **`doc/zfs.md`** — optional ZFS storage backend (`ENROOT_STORAGE_BACKEND=zfs`). Replaces `unsquashfs`-per-create with extract-once-then-`zfs clone`. Adds a `.zfs` (zfs send stream) image format and a `zfs://host/NAME` transport scheme alongside today's `.sqsh`. Introduces a shared template cache with a live/warm/cold lifecycle (knobs: `ENROOT_TEMPLATE_WARM_SECONDS`, `ENROOT_TEMPLATE_PRESSURE_THRESHOLD`; eviction is implicit on `create`, no daemon, no `enroot gc` command). Default backend (`dir`) is unchanged. -- **`doc/plans/`** — six implementation plans (A–F) breaking the ZFS backend into independently-landable slices. Start with `doc/plans/README.md` for the index and recommended landing order (A → E → F → B → C → D). Plans add a new sourced module `src/storage_zfs.sh` (under a `zfs::` namespace) and branch in `src/runtime.sh`, `src/docker.sh` on `ENROOT_STORAGE_BACKEND`. **Plans A, E, F, B merged; C in review** on `zenroot/main` (PRs [zeroae/enroot#1](https://github.com/zeroae/enroot/pull/1), [#2](https://github.com/zeroae/enroot/pull/2), [#3](https://github.com/zeroae/enroot/pull/3), [#5](https://github.com/zeroae/enroot/pull/5), [#7](https://github.com/zeroae/enroot/pull/7)); D is still design-only. +- **`doc/plans/`** — six implementation plans (A–F) breaking the ZFS backend into independently-landable slices. Start with `doc/plans/README.md` for the index and recommended landing order (A → E → F → B → C → D). Plans add a new sourced module `src/storage_zfs.sh` (under a `zfs::` namespace) and branch in `src/runtime.sh`, `src/docker.sh` on `ENROOT_STORAGE_BACKEND`. **All six plans merged** on `zenroot/main` (PRs [#1](https://github.com/zeroae/enroot/pull/1), [#2](https://github.com/zeroae/enroot/pull/2), [#3](https://github.com/zeroae/enroot/pull/3), [#5](https://github.com/zeroae/enroot/pull/5), [#7](https://github.com/zeroae/enroot/pull/7), and Plan D in review). ## Conventions diff --git a/doc/zfs.md b/doc/zfs.md index 0e99576..6100d19 100644 --- a/doc/zfs.md +++ b/doc/zfs.md @@ -1,6 +1,6 @@ # ZFS storage backend -This document describes an optional ZFS-aware mode for the enroot container store. **Plans A (foundation), B (template warm/cold lifecycle), C (`.zfs` image format), E (ephemeral start), and F (Docker load) are implemented**: `enroot create`, `enroot remove`, ephemeral `enroot start `, and `enroot load docker://...` all use ZFS datasets when `ENROOT_STORAGE_BACKEND=zfs`, with a shared template cache that survives `enroot remove` (warm) for `ENROOT_TEMPLATE_WARM_SECONDS` and gets pressure-evicted LRU once the templates dataset crosses `ENROOT_TEMPLATE_PRESSURE_THRESHOLD` of its quota. `enroot create` accepts both `.sqsh` and `.zfs` (zfs send stream) inputs; `enroot export --format=zfs` produces the latter. The remaining transport (`zfs://` URI) is tracked under `doc/plans/`. The default storage backend (plain directories under `ENROOT_DATA_PATH`) is unchanged and remains the only option on hosts without ZFS. +This document describes an optional ZFS-aware mode for the enroot container store. **All six plans (A–F) are implemented.** When `ENROOT_STORAGE_BACKEND=zfs`: `enroot create`, `enroot remove`, ephemeral `enroot start `, and `enroot load docker://...` all use ZFS datasets, with a shared template cache that survives `enroot remove` (warm) for `ENROOT_TEMPLATE_WARM_SECONDS` and gets pressure-evicted LRU once the templates dataset crosses `ENROOT_TEMPLATE_PRESSURE_THRESHOLD` of its quota. `enroot create` accepts both `.sqsh` and `.zfs` (zfs send stream) inputs; `enroot export --format=zfs` produces the latter. The `zfs://[USER@]HOST/NAME` URI scheme transports containers between enroot hosts over SSH (`enroot load zfs://...` to pull, `enroot export NAME zfs://...` to push). The default storage backend (plain directories under `ENROOT_DATA_PATH`) is unchanged and remains the only option on hosts without ZFS. ## Motivation diff --git a/enroot.in b/enroot.in index 99c1661..183fa80 100644 --- a/enroot.in +++ b/enroot.in @@ -170,9 +170,9 @@ enroot::usage() { Create a container image from a container root filesystem. Options: - -o, --output Name of the output image file (defaults to "NAME.sqsh" or "NAME.zfs") + -o, --output Output destination: a filename (defaults to "NAME.sqsh" or "NAME.zfs") or a "zfs://[USER@]HOST[/REMOTE_NAME]" URI to push to a remote enroot host over SSH -f, --force Overwrite an existing container image - --format Output format: "sqsh" (default) or "zfs" (zfs send stream; requires ZFS backend) + --format Output format for file destinations: "sqsh" (default) or "zfs" (zfs send stream; requires ZFS backend) EOF ;; import) @@ -185,10 +185,11 @@ enroot::usage() { docker://[USER@][REGISTRY#]IMAGE[:TAG] Import a Docker image from a registry dockerd://IMAGE[:TAG] Import a Docker image from the Docker daemon podman://IMAGE[:TAG] Import a Docker image from a local podman repository + zfs://[USER@]HOST/NAME Import a container from a remote enroot host (defaults to ".zfs"; ".sqsh" produced if -o ends in .sqsh, requires local ZFS backend) Options: -a, --arch Architecture of the image (defaults to host architecture) - -o, --output Name of the output image file (defaults to "URI.sqsh") + -o, --output Name of the output image file (defaults to "URI.sqsh" or "URI.zfs" for zfs://) EOF ;; digest) @@ -212,6 +213,7 @@ enroot::usage() { Schemes: docker://[USER@][REGISTRY#]IMAGE[:TAG] Load a Docker image from a registry + zfs://[USER@]HOST/NAME Pull a container from a remote enroot host (full zfs send stream over SSH; both hosts must run the ZFS backend) Options: -a, --arch Architecture of the image (defaults to host architecture) @@ -355,7 +357,7 @@ enroot::digest() { } enroot::import() { - local uri= filename= arch= + local uri= filename= arch= name= zfs_recv= while [ $# -gt 0 ]; do case "$1" in @@ -379,6 +381,20 @@ enroot::import() { filename="${1#*=}" shift ;; + -n|--name) + [ -z "${2-}" ] && enroot::usage import 1 + name="$2" + shift 2 + ;; + --name=*) + [ -z "${1#*=}" ] && enroot::usage import 1 + name="${1#*=}" + shift + ;; + --zfs-recv) + zfs_recv=y + shift + ;; -h|--help) enroot::usage import 0 ;; --) @@ -389,6 +405,19 @@ enroot::import() { break ;; esac done + + # Internal --zfs-recv: read a stream from stdin and clone to NAME, no URI. + if [ -n "${zfs_recv}" ]; then + if ! zfs::enabled; then + common::err "--zfs-recv requires ENROOT_STORAGE_BACKEND=zfs" + fi + if [ -z "${name}" ]; then + common::err "--zfs-recv requires -n NAME" + fi + zfs::recv_to_template_stdin "${name}" + return + fi + if [ $# -ne 1 ]; then enroot::usage import 1 fi @@ -445,7 +474,7 @@ enroot::load() { } enroot::export() { - local name= filename= format=sqsh + local name= filename= format=sqsh zfs_send= while [ $# -gt 0 ]; do case "$1" in @@ -473,6 +502,10 @@ enroot::export() { format="${1#*=}" shift ;; + --zfs-send) + zfs_send=y + shift + ;; -h|--help) enroot::usage export 0 ;; --) @@ -488,6 +521,15 @@ enroot::export() { fi name="$1" + # Internal --zfs-send: write a stream to stdout, no filename or format. + if [ -n "${zfs_send}" ]; then + if ! zfs::enabled; then + common::err "--zfs-send requires ENROOT_STORAGE_BACKEND=zfs" + fi + zfs::send_clone_stdout "${name}" + return + fi + runtime::export "${name}" "${filename}" "${format}" } diff --git a/src/runtime.sh b/src/runtime.sh index d41bc9b..b42588b 100644 --- a/src/runtime.sh +++ b/src/runtime.sh @@ -537,6 +537,8 @@ runtime::import() { docker::import "${uri}" "${filename}" "${arch}" ;; dockerd://* | podman://*) docker::daemon::import "${uri}" "${filename}" "${arch}" ;; + zfs://*) + zfs::import_uri "${uri}" "${filename}" ;; *) common::err "Invalid argument: ${uri}" ;; esac @@ -559,6 +561,11 @@ runtime::load() { case "${uri}" in docker://*) docker::load "${uri}" "${rootfs}" "${arch}" ;; + zfs://*) + if ! zfs::enabled; then + common::err "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs" + fi + zfs::pull_via_ssh "${uri}" "${rootfs}" ;; *) common::err "Invalid argument: ${uri}" ;; esac @@ -574,6 +581,15 @@ runtime::export() { common::err "Invalid argument: ${rootfs_name}" fi + # Destination is a zfs:// URI: push to a remote enroot host over SSH. + if [[ "${filename}" == zfs://* ]]; then + if ! zfs::enabled; then + common::err "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs" + fi + zfs::push_via_ssh "${rootfs_name}" "${filename}" + return + fi + case "${format}" in sqsh) ;; zfs) diff --git a/src/storage_zfs.sh b/src/storage_zfs.sh index 96385ea..7c18afd 100644 --- a/src/storage_zfs.sh +++ b/src/storage_zfs.sh @@ -262,6 +262,152 @@ zfs::container_check() { fi } +# Parses a zfs:// URI and prints two lines: the host and the container name +# (NAME may contain extra path components which are reassembled into the +# container name). Matches the docker::_parse_uri output convention so callers +# can use the same `common::read -r` pattern. +zfs::parse_uri() { + local -r uri="$1" + if [[ ! "${uri}" =~ ^zfs://([^/]+)/(.+)$ ]]; then + common::err "Invalid zfs:// URI: ${uri}" + fi + printf "%s\n%s\n" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" +} + +# Sends a clone's @pristine snapshot (or a fresh snapshot if the container is +# not a clone) to stdout. Used by --zfs-send. +zfs::send_clone_stdout() { + local -r name="$1" + local -r store=$(zfs::store_dataset) + local -r target="${store}/${name}" + local origin + + if ! zfs list -H "${target}" > /dev/null 2>&1; then + common::err "No such container: ${name}" + fi + + origin=$(zfs get -H -o value origin "${target}") + if [ -z "${origin}" ] || [ "${origin}" = "-" ]; then + local snap="${target}@enroot-export-$$" + zfs snapshot "${snap}" + trap "zfs destroy '${snap}' 2> /dev/null || :" RETURN + zfs send "${snap}" + else + zfs send "${origin}" + fi +} + +# Receives a zfs send stream from stdin into the template cache, then clones +# the resulting template into a named user container. Used by --zfs-recv. +# The cache key is the sha256 of the (buffered) stream bytes — same scheme as +# zfs::create_from_stream uses for .zfs files. +zfs::recv_to_template_stdin() { + local -r name="$1" + local buf sha template + + buf=$(mktemp -p "${ENROOT_TEMP_PATH:-/tmp}" enroot-recv.XXXXXX) + trap "rm -f '${buf}' 2> /dev/null || :" RETURN + cat > "${buf}" + sha=$(zfs::image_sha256 "${buf}") + template=$(zfs::ensure_template_from_stream "${buf}" "${sha}") + zfs::clone_container "${template}" "${name}" +} + +# Imports a container from a remote enroot host over SSH and writes a file. +# Output format is inferred from the filename extension: ".sqsh" produces a +# squashfs (requires local ZFS to receive into a temp dataset, mksquashfs out, +# then destroy the temp); anything else produces a raw zfs send stream +# (".zfs" by convention; no local ZFS receive required). +zfs::import_uri() { + local -r uri="$1" + local filename="$2" + local host remote_name + + common::checkcmd ssh + zfs::parse_uri "${uri}" \ + | { common::read -r host; common::read -r remote_name; } + + if [ -z "${filename}" ]; then + filename="${remote_name##*/}.zfs" + fi + filename=$(common::realpath "${filename}") + if [ -e "${filename}" ]; then + if [ -z "${ENROOT_FORCE_OVERRIDE-}" ]; then + common::err "File already exists: ${filename}" + else + rm -f "${filename}" + fi + fi + + case "${filename}" in + *.sqsh) + zfs::checkenv + common::checkcmd mksquashfs + local -r store=$(zfs::store_dataset) + local -r tmp_ds="${store}/${zfs_template_subdir}/import-$$.tmp" + local mountpoint + zfs create -p "${store}/${zfs_template_subdir}" 2> /dev/null || : + common::log INFO "Pulling ${remote_name} from ${host} (sqsh)" NL + if ! ssh "${host}" enroot export --zfs-send "${remote_name}" \ + | zfs receive -F "${tmp_ds}"; then + zfs destroy -r "${tmp_ds}" 2> /dev/null || : + common::err "Receive from ${uri} failed" + fi + mountpoint=$(zfs get -H -o value mountpoint "${tmp_ds}") + common::log INFO "Creating squashfs filesystem..." NL + mksquashfs "${mountpoint}" "${filename}" -all-root ${TTY_OFF+-no-progress} \ + -processors "${ENROOT_MAX_PROCESSORS}" ${ENROOT_SQUASH_OPTIONS} >&2 \ + || { zfs destroy -r "${tmp_ds}" 2> /dev/null || :; \ + common::err "mksquashfs failed"; } + zfs destroy -r "${tmp_ds}" + ;; + *) + common::log INFO "Pulling ${remote_name} from ${host} (zfs send stream)" NL + ssh "${host}" enroot export --zfs-send "${remote_name}" > "${filename}" \ + || { rm -f "${filename}"; common::err "ssh transport failed for ${uri}"; } + ;; + esac +} + +# Pulls a container from a remote enroot host over SSH. URI is zfs://host/NAME; +# the SSH peer must be running enroot with the ZFS backend. Local NAME +# defaults to the URI's basename if not given. +zfs::pull_via_ssh() { + local -r uri="$1" + local name="$2" + local host remote_name + + common::checkcmd ssh + zfs::parse_uri "${uri}" \ + | { common::read -r host; common::read -r remote_name; } + + [ -z "${name}" ] && name="${remote_name##*/}" + + common::log INFO "Pulling ${remote_name} from ${host}" NL + ssh "${host}" enroot export --zfs-send "${remote_name}" \ + | zfs::recv_to_template_stdin "${name}" +} + +# Pushes a local container to a remote enroot host over SSH. URI may be +# zfs://host (push under the same NAME) or zfs://host/REMOTE_NAME (rename on +# the remote side). +zfs::push_via_ssh() { + local -r name="$1" uri="$2" + local host remote_name + + common::checkcmd ssh + if [[ "${uri}" =~ ^zfs://([^/]+)/?(.*)$ ]]; then + host="${BASH_REMATCH[1]}" + remote_name="${BASH_REMATCH[2]:-${name}}" + else + common::err "Invalid zfs:// URI: ${uri}" + fi + + common::log INFO "Pushing ${name} to ${host} as ${remote_name}" NL + zfs::send_clone_stdout "${name}" \ + | ssh "${host}" enroot import --zfs-recv -n "${remote_name}" +} + # Materializes a ZFS stream file into a template (cached by file sha) and # clones it as the user's named container. Counterpart of zfs::ensure_template # + zfs::clone_container for the .sqsh path; this is called from runtime::create