Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion doc/zfs.md
Original file line number Diff line number Diff line change
@@ -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 <image>`, 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 <image>`, 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

Expand Down
52 changes: 47 additions & 5 deletions enroot.in
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 ;;
--)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -473,6 +502,10 @@ enroot::export() {
format="${1#*=}"
shift
;;
--zfs-send)
zfs_send=y
shift
;;
-h|--help)
enroot::usage export 0 ;;
--)
Expand All @@ -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}"
}

Expand Down
16 changes: 16 additions & 0 deletions src/runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
146 changes: 146 additions & 0 deletions src/storage_zfs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down