Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Plan D: zfs:// URI transport over SSH - #8

Merged
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri
Apr 29, 2026
Merged

Plan D: zfs:// URI transport over SSH#8
sodre merged 8 commits into
zenroot/mainfrom
feature/zfs-d-zfs-uri

Conversation

@sodre

@sodresodre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a zfs://[USER@]HOST/NAME URI scheme for transporting containers between enroot hosts as zfs send streams over SSH. Both ends must run enroot with ENROOT_STORAGE_BACKEND=zfs.

  • enroot load zfs://host/NAME — pull and instantiate as a container in one step.
  • enroot export NAME zfs://host (or zfs://host/REMOTE) — push a local container to a remote enroot host.
  • enroot import -o file.zfs zfs://host/NAME — pull and save as a portable file (.zfs by default; .sqsh if the output filename ends in .sqsh).

Implements Plan D. Builds on Plans A (#1), C (#7).

What changed

All ZFS-specific lifecycle stays in src/storage_zfs.sh; touch points in enroot.in and src/runtime.sh are minimal dispatches.

  • src/storage_zfs.sh — six new helpers (all zfs::*):
    • zfs::parse_uri URIzfs://host/NAME → two lines (host then NAME), matching docker::_parse_uri's convention.
    • zfs::send_clone_stdout NAMEzfs send the clone's @pristine (or fresh snapshot for non-clones) to stdout.
    • zfs::recv_to_template_stdin NAME — buffers stdin to a temp file, hashes for cache key, materializes via zfs::ensure_template_from_stream, clones to NAME. Same Plan B sweep + touch lifecycle.
    • zfs::pull_via_ssh URI [NAME]ssh host enroot export --zfs-send REMOTE | zfs::recv_to_template_stdin LOCAL.
    • zfs::push_via_ssh NAME URIzfs::send_clone_stdout NAME | ssh host enroot import --zfs-recv -n REMOTE.
    • zfs::import_uri URI FILENAME — pulls and writes a file. Format inferred from output extension: .sqsh triggers a recv-into-temp-dataset → mksquashfs → destroy-temp pipeline (requires local ZFS); anything else streams the raw zfs send bytes to file (no local ZFS receive required).
  • enroot.in — added --zfs-send to enroot::export and --zfs-recv (with -n NAME) to enroot::import. Both gate on ENROOT_STORAGE_BACKEND=zfs. Internal flags used by the SSH peer side. Updated import / load / export usage blocks to document zfs://.
  • src/runtime.sh — three thin dispatches: runtime::load routes zfs://* to zfs::pull_via_ssh; runtime::export routes zfs://* destinations to zfs::push_via_ssh; runtime::import routes zfs://* to zfs::import_uri.
  • doc/zfs.md + CLAUDE.md — status notes flipped to "All six plans implemented".

Why enroot import zfs:// is allowed (originally was rejected)

Initial design treated zfs:// as transport-only — import would produce no portable artifact, so it was rejected with "use 'enroot load'". That framing missed a real case: a .zfs file IS portable, just to ZFS hosts. And a .sqsh produced from a remote ZFS source serves a useful relay workflow (pull on a host with SSH access, transfer the file to a host without). The current behavior accepts both forms; format is inferred from the output filename extension so no new flag is needed.

Wire format and privacy

The wire is just ssh host enroot ... invocations. The URI names a container on a remote enroot host, not a raw dataset path. The remote's pool name and dataset hierarchy never leak — the remote enroot resolves its own store layout. (One small caveat: zfs send includes the source snapshot's full dataset path in the stream header. For trusted-host replication this is fine; for cross-org transfers it leaks the sender's pool naming. Same as the .zfs file format from Plan C.)

Test Plan

Verified manually with passwordless SSH to root@localhost on a loopback ZFS pool (Linux 6.12.75, aarch64, zfs-2.4.1):

  • enroot load zfs://root@localhost/donor pulls a clone, rootfs readable, enroot start pulled /bin/cat /etc/os-release prints alpine os-release.
  • enroot export pulled -o zfs://root@localhost/pushed ships the container; the remote enroot list shows pushed.
  • enroot import -o file.zfs zfs://... writes a 10MB ZFS send stream; subsequent enroot create file.zfs instantiates correctly.
  • enroot import -o file.sqsh zfs://... writes a valid 8MB squashfs (recv-temp-dataset path); the resulting .sqsh works on the dir backend with no local ZFS required.
  • Hard error without ZFS backend: enroot load zfs://... errors with "zfs:// URIs require ENROOT_STORAGE_BACKEND=zfs".
  • Hard error without ZFS backend on push: enroot export -o zfs://... errors with "zfs:// destinations require ENROOT_STORAGE_BACKEND=zfs".
  • zfs::recv_to_template_stdin integrates with Plan B: sweep + touch fire on the receive side too.

Known limitations

  • No incremental sends (zfs send -i) — full streams only. A future iteration could add --since=BASE and have the receiver enumerate locally-cached templates over the wire to find a common ancestor; out of scope here.
  • Stream metadata leaks the sender's pool/dataset path in the snapshot name embedded in the stream header. Same caveat as .zfs files (Plan C).
  • No fallback if the remote enroot doesn't speak --zfs-send / --zfs-recv. The remote will print its usage and produce no stream; the local side will fail receive. Acceptable per the design — silent fallback would be confusing.
  • Authentication via standard SSH config. Passwordless SSH and any ssh_config Host blocks (port, identity, jump hosts) are honored because we shell out to ssh.
  • enroot import -o file.sqsh zfs://... requires local ZFS to do the temp-dataset receive + mksquashfs transform. This is a mild surprise but unavoidable without remote-side cooperation.

sodre added 8 commits April 29, 2026 11:14
Three new helpers under the zfs:: namespace, all in storage_zfs.sh
to keep ZFS code together:
- zfs::parse_uri URI
Parses zfs://host/NAME → 'host\tNAME'. Errors on malformed
input. Supports user@host and multi-segment NAMEs.
- zfs::send_clone_stdout NAME
zfs send the clone's @pristine origin (or a fresh snapshot if
the container isn't a clone) to stdout. RETURN-trap cleans up
the temporary snapshot in the non-clone case.
- zfs::recv_to_template_stdin NAME
Buffers stdin to a temp file, hashes it as the cache key, then
materializes the template via zfs::ensure_template_from_stream
and clones to NAME. Same Plan B sweep + touch lifecycle as the
.zfs file path.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
High-level wrappers over the SSH transport. The wire is just
'ssh host enroot export --zfs-send NAME' / '... import --zfs-recv -n NAME';
the local side calls zfs::recv_to_template_stdin /
zfs::send_clone_stdout. Pool name and dataset paths never leak
across the wire — the URI names a container, and remote enroot
resolves the local layout itself.
push_via_ssh accepts zfs://host (push under the same NAME) or
zfs://host/REMOTE (rename on the remote side).
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Used by zfs::pull_via_ssh and zfs::push_via_ssh on the SSH peer
side. Not surfaced in user-facing usage (kept brief by being
internal). Both gate on ENROOT_STORAGE_BACKEND=zfs.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
…me::import
runtime::load zfs://host/NAME → zfs::pull_via_ssh
runtime::export destination zfs://... → zfs::push_via_ssh
runtime::import zfs://... → hard error (use 'enroot load')
Each backend dispatch is one to two lines; the SSH transport
itself lives in storage_zfs.sh.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
zfs::pull_via_ssh and zfs::push_via_ssh consume parse_uri output
via 'common::read -r host; common::read -r remote_name', expecting
two newline-separated lines (matching docker::_parse_uri's output
convention). The earlier tab-separated single-line output caused
read to wait 30s on the second component and then time out.
Verified end-to-end with passwordless SSH to root@localhost: pull,
push, and import-rejection all work.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
Reverses the earlier 'no portable file artifact' rejection.
The user's argument: a .zfs file is portable to ZFS hosts, and
producing a .sqsh from a remote ZFS source is a real workflow
(relay scenario, transferring to non-ZFS targets).
Format is inferred from the output filename extension:
enroot import -o foo.zfs zfs://host/NAME # default (.zfs)
enroot import -o foo.sqsh zfs://host/NAME # transcode to .sqsh
The .zfs path streams ssh stdout to the file directly — no local
ZFS required (the produced file is only useful on a ZFS host, but
we don't enforce that on the producer side).
The .sqsh path requires local ZFS: receive the stream into a
one-off temp dataset under .templates/, mksquashfs from its
mountpoint to the output file, then destroy the temp dataset.
Implementation lives in src/storage_zfs.sh as zfs::import_uri;
runtime::import dispatches via a one-line case branch.
Signed-off-by: Patrick Sodré <patrick@zero-ae.com>
@sodre
sodre merged commit b99d33c into zenroot/mainApr 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sodre