diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index ef38bee..eff545b 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -2,12 +2,17 @@ name: CI
run-name: CI for ${{ github.ref }}
# Build + test on PR and on push to main. No publishing.
+# Rock-only changes are handled by rocks-ci.yaml -- ignore them here.
on:
pull_request:
+ paths-ignore:
+ - 'rocks/**'
push:
branches:
- main
+ paths-ignore:
+ - 'rocks/**'
workflow_dispatch:
# Cancel in-flight runs on the same ref when a newer one starts.
diff --git a/.github/workflows/rocks-ci.yaml b/.github/workflows/rocks-ci.yaml
new file mode 100644
index 0000000..fdf0c81
--- /dev/null
+++ b/.github/workflows/rocks-ci.yaml
@@ -0,0 +1,112 @@
+name: rocks CI
+run-name: rocks CI for ${{ github.ref }}
+
+# Build + test the chiselled rocks on PR + push to main. No publishing.
+# Build packs each rock per-arch (native runners); test loads that arch's rocks
+# into the local daemon and runs the single top-level spread suite over the
+# native-arch systems.
+on:
+ pull_request:
+ paths:
+ - 'rocks/**'
+ - '.github/workflows/rocks-ci.yaml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'rocks/**'
+ - '.github/workflows/rocks-ci.yaml'
+ workflow_dispatch:
+
+concurrency:
+ group: rocks-ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ dir:
+ - bread-24.04
+ - bread-25.10
+ - bread-26.04
+ - bread-chisel-releases-24.04
+ - bread-chisel-releases-25.10
+ - bread-chisel-releases-26.04
+ arch:
+ - amd64
+ - arm64
+ include:
+ - arch: amd64
+ runner: ubuntu-24.04
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Sync shared hack/ + patches/ into the rock dir
+ run: ruby rocks/sync_shared.rb ${{ matrix.dir }}
+
+ - name: Pack rock
+ uses: canonical/craft-actions/rockcraft-pack@main
+ with:
+ path: rocks/${{ matrix.dir }}
+ verbosity: brief
+
+ - name: Rename to predictable artefact name
+ run: mv rocks/${{ matrix.dir }}/*.rock rocks/${{ matrix.dir }}/${{ matrix.dir }}.rock
+
+ - name: Upload rock artefact
+ uses: actions/upload-artifact@v6
+ with:
+ name: rock-${{ matrix.dir }}-${{ matrix.arch }}
+ path: rocks/${{ matrix.dir }}/${{ matrix.dir }}.rock
+
+ test:
+ needs: build
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ runner: ubuntu-24.04
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Download this arch's rocks
+ uses: actions/download-artifact@v6
+ with:
+ pattern: rock-*-${{ matrix.arch }}
+ path: dl
+
+ - name: Place rocks under their dirs
+ run: |
+ set -e
+ for d in dl/rock-*-${{ matrix.arch }}; do
+ base=$(basename "$d") # rock-
-
+ dir=${base#rock-}
+ dir=${dir%-${{ matrix.arch }}}
+ cp "$d/$dir.rock" "rocks/$dir/$dir.rock"
+ done
+
+ - name: Install spread + skopeo
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y skopeo
+ go install github.com/canonical/spread/cmd/spread@latest
+ echo "$HOME/go/bin" >> "$GITHUB_PATH"
+
+ - name: Run the top-level spread suite (${{ matrix.arch }})
+ run: make -C rocks spread-only
diff --git a/.gitignore b/.gitignore
index ba02087..907c629 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,14 @@
.spread-reuse*.yaml
.stamp/
cache/
+
+# rockcraft (rocks/) build artefacts
+*.rock
+.rock.stamp
+
+# per-rock hack/ + patches/ are synced from rocks/hack + rocks/patches
+# (see rocks/sync_shared.rb) -- only the shared source of truth is tracked.
+rocks/bread-*/hack/
+rocks/bread-*/patches/
__pycache__/
*.pyc
diff --git a/rocks/bread-24.04/rockcraft.yaml b/rocks/bread-24.04/rockcraft.yaml
new file mode 100644
index 0000000..b6ec2b1
--- /dev/null
+++ b/rocks/bread-24.04/rockcraft.yaml
@@ -0,0 +1,117 @@
+# spellchecker: ignore rockcraft chiselled coreutils passwd nologin sshd usrmerge pebble noninteractive
+
+name: bread
+version: "24.04"
+summary: chiselled ubuntu 24.04 + sshd spread test-host rock
+description: |
+ Throwaway spread test-host: a chiselled (base: bare) Ubuntu 24.04 rock with
+ just enough to act as a spread system -- bash, coreutils, openssh-server,
+ apt, and a pam_permit-only auth stack (accepts ANY password for root).
+
+ INSECURE BY DESIGN. Built for ephemeral spread test containers only; never
+ expose to a network or use in production.
+
+base: bare
+build-base: ubuntu@24.04
+
+platforms:
+ amd64:
+ arm64:
+
+# sshd runs as a pebble service (pebble is pid 1 in the rock). The
+# openssh-server_services slice ships systemd units, not a pebble layer, so we
+# drive sshd ourselves via hack/sshd-entry.sh (host-key gen + exec sshd -D).
+services:
+ sshd:
+ override: replace
+ command: /usr/local/bin/sshd-entry.sh
+ startup: enabled
+
+parts:
+ bread:
+ # We chisel-cut the slices ourselves rather than using stage-packages:
+ # rockcraft's stage-packages cut has no retry, and the chisel-releases
+ # archive intermittently flakes ("expected digest ..."). hack/chisel_cut.sh
+ # retries around that. (Pattern from not-quite-rust-rock.)
+ plugin: nil
+ build-snaps:
+ - chisel
+ build-packages:
+ - git
+ override-build: |
+ set -eu
+
+ releases=$(mktemp -d)
+ git clone --depth 1 --branch ubuntu-24.04 \
+ https://github.com/canonical/chisel-releases.git "$releases"
+
+ # tar + gzip: needed by spread to sync the project onto the system over
+ # ssh (not part of coreutils). dpkg_bins: apt_apt-get only pulls
+ # dpkg_maintscript-support, so apt-get install can't unpack without it.
+ # pam: only libpam-modules_libs (carries pam_permit.so) -- NOT the
+ # _config / _pam-config default stacks.
+ bash "$CRAFT_PROJECT_DIR/hack/chisel_cut.sh" "$releases" \
+ base-files_base base-files_etc base-files_var base-files_tmp \
+ base-files_lib base-files_home base-files_release-info base-files_chisel \
+ bash_bins coreutils_bins tar_bins gzip_bins \
+ base-passwd_data login_bins passwd_bins \
+ openssh-server_bins openssh-server_config ca-certificates_data \
+ libpam-modules_libs \
+ apt_apt-get apt_config apt_var apt_default-source-list dpkg_bins
+
+ cd "$CRAFT_PART_INSTALL"
+
+ # --- users: base-passwd_data gives us /etc/passwd + /etc/group (incl
+ # root), but not the sshd privsep user nor any /etc/shadow. add both.
+ grep -q '^sshd:' etc/passwd || \
+ echo 'sshd:x:101:65534::/run/sshd:/usr/sbin/nologin' >> etc/passwd
+ # root password "bread" (matches the docker-image flavour). pam_permit
+ # makes the value irrelevant for auth, but root must not be locked.
+ hash=$(openssl passwd -6 bread)
+ {
+ printf 'root:%s:20000:0:99999:7:::\n' "$hash"
+ printf 'sshd:!:20000:0:99999:7:::\n'
+ } > etc/shadow
+ chmod 0640 etc/shadow
+
+ # --- sshd config. openssh-server_config ships sshd_config.d/ but not
+ # /etc/ssh/sshd_config itself, so write a minimal one.
+ mkdir -p etc/ssh
+ cat > etc/ssh/sshd_config <<'EOF'
+ PermitRootLogin yes
+ PasswordAuthentication yes
+ UsePAM yes
+ Subsystem sftp /usr/lib/openssh/sftp-server
+ PidFile /run/sshd.pid
+ EOF
+
+ # --- pam: permit-only stack (always-allow), verbatim shape from the
+ # docker-image flavour.
+ mkdir -p etc/pam.d
+ printf '%s\n' \
+ 'auth required pam_permit.so' \
+ 'account required pam_permit.so' \
+ 'session required pam_permit.so' \
+ 'password required pam_permit.so' \
+ > etc/pam.d/sshd
+
+ # --- /etc/profile: base-files ships profile.d/ but not /etc/profile, so
+ # login shells wouldn't source the bread-warning banner. add a minimal one.
+ cat > etc/profile <<'EOF'
+ if [ -d /etc/profile.d ]; then
+ for __pf in /etc/profile.d/*.sh; do
+ [ -r "$__pf" ] && . "$__pf"
+ done
+ unset __pf
+ fi
+ EOF
+
+ # --- bread bits: sshd entrypoint, lazy-apt shim, insecure-image banner.
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/sshd-entry.sh" usr/local/bin/sshd-entry.sh
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt-get
+ install -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/banner.txt" etc/bread-banner.txt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/bread-warning.sh" etc/profile.d/00-bread-warning.sh
+ mkdir -p root
+ printf '\n# bread-warning\n[ -r /etc/profile.d/00-bread-warning.sh ] && . /etc/profile.d/00-bread-warning.sh\n' \
+ >> root/.bashrc
diff --git a/rocks/bread-25.10/rockcraft.yaml b/rocks/bread-25.10/rockcraft.yaml
new file mode 100644
index 0000000..f680333
--- /dev/null
+++ b/rocks/bread-25.10/rockcraft.yaml
@@ -0,0 +1,126 @@
+# spellchecker: ignore rockcraft chiselled coreutils passwd nologin sshd usrmerge pebble noninteractive
+
+name: bread
+version: "25.10"
+summary: chiselled ubuntu 25.10 + sshd spread test-host rock
+description: |
+ Throwaway spread test-host: a chiselled (base: bare) Ubuntu 25.10 rock with
+ just enough to act as a spread system -- bash, coreutils, openssh-server,
+ apt, and a pam_permit-only auth stack (accepts ANY password for root).
+
+ INSECURE BY DESIGN. Built for ephemeral spread test containers only; never
+ expose to a network or use in production.
+
+base: bare
+build-base: ubuntu@25.10
+
+platforms:
+ amd64:
+ arm64:
+
+# sshd runs as a pebble service (pebble is pid 1 in the rock). The
+# openssh-server_services slice ships systemd units, not a pebble layer, so we
+# drive sshd ourselves via hack/sshd-entry.sh (host-key gen + exec sshd -D).
+services:
+ sshd:
+ override: replace
+ command: /usr/local/bin/sshd-entry.sh
+ startup: enabled
+
+parts:
+ bread:
+ # We chisel-cut the slices ourselves rather than using stage-packages:
+ # rockcraft's stage-packages cut has no retry, and the chisel-releases
+ # archive intermittently flakes ("expected digest ..."). hack/chisel_cut.sh
+ # retries around that. (Pattern from not-quite-rust-rock.)
+ plugin: nil
+ build-snaps:
+ - chisel
+ build-packages:
+ - git
+ override-build: |
+ set -eu
+
+ releases=$(mktemp -d)
+ git clone --depth 1 --branch ubuntu-25.10 \
+ https://github.com/canonical/chisel-releases.git "$releases"
+
+ # OpenSSH >=9.8 (this release) re-execs sshd-session + sshd-auth per
+ # connection; the openssh-server slice ships only /usr/sbin/sshd, so sshd
+ # exits 255 at startup. Add them to the cloned slice before cutting.
+ # (24.04's OpenSSH 9.6 predates the split and has no such binaries.)
+ sed -i \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-session:' \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-auth:' \
+ "$releases/slices/openssh-server.yaml"
+
+ # tar + gzip: needed by spread to sync the project onto the system over
+ # ssh (not part of coreutils). dpkg_bins: apt_apt-get only pulls
+ # dpkg_maintscript-support, so apt-get install can't unpack without it.
+ # pam: only libpam-modules_libs (carries pam_permit.so) -- NOT the
+ # _config / _pam-config default stacks.
+ bash "$CRAFT_PROJECT_DIR/hack/chisel_cut.sh" "$releases" \
+ base-files_base base-files_etc base-files_var base-files_tmp \
+ base-files_lib base-files_home base-files_release-info base-files_chisel \
+ bash_bins coreutils_bins tar_bins gzip_bins \
+ base-passwd_data login_bins passwd_bins \
+ openssh-server_bins openssh-server_config ca-certificates_data \
+ libpam-modules_libs \
+ apt_apt-get apt_config apt_var apt_default-source-list dpkg_bins
+
+ cd "$CRAFT_PART_INSTALL"
+
+ # --- users: base-passwd_data gives us /etc/passwd + /etc/group (incl
+ # root), but not the sshd privsep user nor any /etc/shadow. add both.
+ grep -q '^sshd:' etc/passwd || \
+ echo 'sshd:x:101:65534::/run/sshd:/usr/sbin/nologin' >> etc/passwd
+ # root password "bread" (matches the docker-image flavour). pam_permit
+ # makes the value irrelevant for auth, but root must not be locked.
+ hash=$(openssl passwd -6 bread)
+ {
+ printf 'root:%s:20000:0:99999:7:::\n' "$hash"
+ printf 'sshd:!:20000:0:99999:7:::\n'
+ } > etc/shadow
+ chmod 0640 etc/shadow
+
+ # --- sshd config. openssh-server_config ships sshd_config.d/ but not
+ # /etc/ssh/sshd_config itself, so write a minimal one.
+ mkdir -p etc/ssh
+ cat > etc/ssh/sshd_config <<'EOF'
+ PermitRootLogin yes
+ PasswordAuthentication yes
+ UsePAM yes
+ Subsystem sftp /usr/lib/openssh/sftp-server
+ PidFile /run/sshd.pid
+ EOF
+
+ # --- pam: permit-only stack (always-allow), verbatim shape from the
+ # docker-image flavour.
+ mkdir -p etc/pam.d
+ printf '%s\n' \
+ 'auth required pam_permit.so' \
+ 'account required pam_permit.so' \
+ 'session required pam_permit.so' \
+ 'password required pam_permit.so' \
+ > etc/pam.d/sshd
+
+ # --- /etc/profile: base-files ships profile.d/ but not /etc/profile, so
+ # login shells wouldn't source the bread-warning banner. add a minimal one.
+ cat > etc/profile <<'EOF'
+ if [ -d /etc/profile.d ]; then
+ for __pf in /etc/profile.d/*.sh; do
+ [ -r "$__pf" ] && . "$__pf"
+ done
+ unset __pf
+ fi
+ EOF
+
+ # --- bread bits: sshd entrypoint, lazy-apt shim, insecure-image banner.
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/sshd-entry.sh" usr/local/bin/sshd-entry.sh
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt-get
+ install -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/banner.txt" etc/bread-banner.txt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/bread-warning.sh" etc/profile.d/00-bread-warning.sh
+ mkdir -p root
+ printf '\n# bread-warning\n[ -r /etc/profile.d/00-bread-warning.sh ] && . /etc/profile.d/00-bread-warning.sh\n' \
+ >> root/.bashrc
diff --git a/rocks/bread-26.04/rockcraft.yaml b/rocks/bread-26.04/rockcraft.yaml
new file mode 100644
index 0000000..98d2a61
--- /dev/null
+++ b/rocks/bread-26.04/rockcraft.yaml
@@ -0,0 +1,126 @@
+# spellchecker: ignore rockcraft chiselled coreutils passwd nologin sshd usrmerge pebble noninteractive
+
+name: bread
+version: "26.04"
+summary: chiselled ubuntu 26.04 + sshd spread test-host rock
+description: |
+ Throwaway spread test-host: a chiselled (base: bare) Ubuntu 26.04 rock with
+ just enough to act as a spread system -- bash, coreutils, openssh-server,
+ apt, and a pam_permit-only auth stack (accepts ANY password for root).
+
+ INSECURE BY DESIGN. Built for ephemeral spread test containers only; never
+ expose to a network or use in production.
+
+base: bare
+build-base: ubuntu@26.04
+
+platforms:
+ amd64:
+ arm64:
+
+# sshd runs as a pebble service (pebble is pid 1 in the rock). The
+# openssh-server_services slice ships systemd units, not a pebble layer, so we
+# drive sshd ourselves via hack/sshd-entry.sh (host-key gen + exec sshd -D).
+services:
+ sshd:
+ override: replace
+ command: /usr/local/bin/sshd-entry.sh
+ startup: enabled
+
+parts:
+ bread:
+ # We chisel-cut the slices ourselves rather than using stage-packages:
+ # rockcraft's stage-packages cut has no retry, and the chisel-releases
+ # archive intermittently flakes ("expected digest ..."). hack/chisel_cut.sh
+ # retries around that. (Pattern from not-quite-rust-rock.)
+ plugin: nil
+ build-snaps:
+ - chisel
+ build-packages:
+ - git
+ override-build: |
+ set -eu
+
+ releases=$(mktemp -d)
+ git clone --depth 1 --branch ubuntu-26.04 \
+ https://github.com/canonical/chisel-releases.git "$releases"
+
+ # OpenSSH >=9.8 (this release) re-execs sshd-session + sshd-auth per
+ # connection; the openssh-server slice ships only /usr/sbin/sshd, so sshd
+ # exits 255 at startup. Add them to the cloned slice before cutting.
+ # (24.04's OpenSSH 9.6 predates the split and has no such binaries.)
+ sed -i \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-session:' \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-auth:' \
+ "$releases/slices/openssh-server.yaml"
+
+ # tar + gzip: needed by spread to sync the project onto the system over
+ # ssh (not part of coreutils). dpkg_bins: apt_apt-get only pulls
+ # dpkg_maintscript-support, so apt-get install can't unpack without it.
+ # pam: only libpam-modules_libs (carries pam_permit.so) -- NOT the
+ # _config / _pam-config default stacks.
+ bash "$CRAFT_PROJECT_DIR/hack/chisel_cut.sh" "$releases" \
+ base-files_base base-files_etc base-files_var base-files_tmp \
+ base-files_lib base-files_home base-files_release-info base-files_chisel \
+ bash_bins coreutils_bins tar_bins gzip_bins \
+ base-passwd_data login_bins passwd_bins \
+ openssh-server_bins openssh-server_config ca-certificates_data \
+ libpam-modules_libs \
+ apt_apt-get apt_config apt_var apt_default-source-list dpkg_bins
+
+ cd "$CRAFT_PART_INSTALL"
+
+ # --- users: base-passwd_data gives us /etc/passwd + /etc/group (incl
+ # root), but not the sshd privsep user nor any /etc/shadow. add both.
+ grep -q '^sshd:' etc/passwd || \
+ echo 'sshd:x:101:65534::/run/sshd:/usr/sbin/nologin' >> etc/passwd
+ # root password "bread" (matches the docker-image flavour). pam_permit
+ # makes the value irrelevant for auth, but root must not be locked.
+ hash=$(openssl passwd -6 bread)
+ {
+ printf 'root:%s:20000:0:99999:7:::\n' "$hash"
+ printf 'sshd:!:20000:0:99999:7:::\n'
+ } > etc/shadow
+ chmod 0640 etc/shadow
+
+ # --- sshd config. openssh-server_config ships sshd_config.d/ but not
+ # /etc/ssh/sshd_config itself, so write a minimal one.
+ mkdir -p etc/ssh
+ cat > etc/ssh/sshd_config <<'EOF'
+ PermitRootLogin yes
+ PasswordAuthentication yes
+ UsePAM yes
+ Subsystem sftp /usr/lib/openssh/sftp-server
+ PidFile /run/sshd.pid
+ EOF
+
+ # --- pam: permit-only stack (always-allow), verbatim shape from the
+ # docker-image flavour.
+ mkdir -p etc/pam.d
+ printf '%s\n' \
+ 'auth required pam_permit.so' \
+ 'account required pam_permit.so' \
+ 'session required pam_permit.so' \
+ 'password required pam_permit.so' \
+ > etc/pam.d/sshd
+
+ # --- /etc/profile: base-files ships profile.d/ but not /etc/profile, so
+ # login shells wouldn't source the bread-warning banner. add a minimal one.
+ cat > etc/profile <<'EOF'
+ if [ -d /etc/profile.d ]; then
+ for __pf in /etc/profile.d/*.sh; do
+ [ -r "$__pf" ] && . "$__pf"
+ done
+ unset __pf
+ fi
+ EOF
+
+ # --- bread bits: sshd entrypoint, lazy-apt shim, insecure-image banner.
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/sshd-entry.sh" usr/local/bin/sshd-entry.sh
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt-get
+ install -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/banner.txt" etc/bread-banner.txt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/bread-warning.sh" etc/profile.d/00-bread-warning.sh
+ mkdir -p root
+ printf '\n# bread-warning\n[ -r /etc/profile.d/00-bread-warning.sh ] && . /etc/profile.d/00-bread-warning.sh\n' \
+ >> root/.bashrc
diff --git a/rocks/bread-chisel-releases-24.04/rockcraft.yaml b/rocks/bread-chisel-releases-24.04/rockcraft.yaml
new file mode 100644
index 0000000..5cca426
--- /dev/null
+++ b/rocks/bread-chisel-releases-24.04/rockcraft.yaml
@@ -0,0 +1,123 @@
+# spellchecker: ignore rockcraft chiselled coreutils passwd nologin sshd pebble chisel CHISEL GOTOOLCHAIN noninteractive
+
+name: bread-chisel-releases
+version: "24.04"
+summary: chiselled ubuntu 24.04 + sshd + chisel + tooling spread test-host rock
+description: |
+ Throwaway spread test-host for chisel-releases spread tests: a chiselled
+ (base: bare) Ubuntu 24.04 rock with bash, coreutils, openssh-server, apt, a
+ pam_permit-only auth stack, plus chisel (+ chisel-hacked), docker, and the
+ tooling chisel-releases tests typically need (curl, wget, git, jq, file,
+ sudo, ip).
+
+ INSECURE BY DESIGN. Built for ephemeral spread test containers only; never
+ expose to a network or use in production.
+
+base: bare
+build-base: ubuntu@24.04
+
+platforms:
+ amd64:
+ arm64:
+
+services:
+ sshd:
+ override: replace
+ command: /usr/local/bin/sshd-entry.sh
+ startup: enabled
+
+parts:
+ # --- de-novo copy of the bread base (rocks can't build from rocks), with
+ # the chisel-releases tooling slices folded into the same single cut. ------
+ bread:
+ plugin: nil
+ build-snaps:
+ - chisel
+ build-packages:
+ - git
+ override-build: |
+ set -eu
+
+ releases=$(mktemp -d)
+ git clone --depth 1 --branch ubuntu-24.04 \
+ https://github.com/canonical/chisel-releases.git "$releases"
+
+ # bread base slices + chisel-releases tooling slices, one cut.
+ # holes: tree + skopeo have no slice yet -> omitted.
+ bash "$CRAFT_PROJECT_DIR/hack/chisel_cut.sh" "$releases" \
+ base-files_base base-files_etc base-files_var base-files_tmp \
+ base-files_lib base-files_home base-files_release-info base-files_chisel \
+ bash_bins coreutils_bins tar_bins gzip_bins \
+ base-passwd_data login_bins passwd_bins \
+ openssh-server_bins openssh-server_config ca-certificates_data \
+ libpam-modules_libs \
+ apt_apt-get apt_config apt_var apt_default-source-list dpkg_bins \
+ curl_bins wget_bins git_bins git_http-support jq_bins file_bins \
+ sudo_bins sudo_config iproute2_bins
+
+ cd "$CRAFT_PART_INSTALL"
+
+ grep -q '^sshd:' etc/passwd || \
+ echo 'sshd:x:101:65534::/run/sshd:/usr/sbin/nologin' >> etc/passwd
+ hash=$(openssl passwd -6 bread)
+ {
+ printf 'root:%s:20000:0:99999:7:::\n' "$hash"
+ printf 'sshd:!:20000:0:99999:7:::\n'
+ } > etc/shadow
+ chmod 0640 etc/shadow
+
+ mkdir -p etc/ssh
+ cat > etc/ssh/sshd_config <<'EOF'
+ PermitRootLogin yes
+ PasswordAuthentication yes
+ UsePAM yes
+ Subsystem sftp /usr/lib/openssh/sftp-server
+ PidFile /run/sshd.pid
+ EOF
+
+ mkdir -p etc/pam.d
+ printf '%s\n' \
+ 'auth required pam_permit.so' \
+ 'account required pam_permit.so' \
+ 'session required pam_permit.so' \
+ 'password required pam_permit.so' \
+ > etc/pam.d/sshd
+
+ cat > etc/profile <<'EOF'
+ if [ -d /etc/profile.d ]; then
+ for __pf in /etc/profile.d/*.sh; do
+ [ -r "$__pf" ] && . "$__pf"
+ done
+ unset __pf
+ fi
+ EOF
+
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/sshd-entry.sh" usr/local/bin/sshd-entry.sh
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt-get
+ install -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/banner.txt" etc/bread-banner.txt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/bread-warning.sh" etc/profile.d/00-bread-warning.sh
+ mkdir -p root
+ printf '\n# bread-warning\n[ -r /etc/profile.d/00-bread-warning.sh ] && . /etc/profile.d/00-bread-warning.sh\n' \
+ >> root/.bashrc
+
+ # --- chisel + chisel-hacked, built from source (go is build-only) --------
+ chisel:
+ plugin: nil
+ build-snaps:
+ - go
+ build-packages:
+ - git
+ build-environment:
+ - CHISEL_REF: v1.4.1
+ override-build: bash "$CRAFT_PROJECT_DIR/hack/chisel_override_build.sh"
+
+ # --- docker static CLI ---------------------------------------------------
+ docker:
+ plugin: nil
+ build-packages:
+ - curl
+ - ca-certificates
+ build-environment:
+ - DOCKER_VERSION: 29.5.2
+ override-build: bash "$CRAFT_PROJECT_DIR/hack/docker_override_build.sh"
diff --git a/rocks/bread-chisel-releases-25.10/rockcraft.yaml b/rocks/bread-chisel-releases-25.10/rockcraft.yaml
new file mode 100644
index 0000000..292b67a
--- /dev/null
+++ b/rocks/bread-chisel-releases-25.10/rockcraft.yaml
@@ -0,0 +1,132 @@
+# spellchecker: ignore rockcraft chiselled coreutils passwd nologin sshd pebble chisel CHISEL GOTOOLCHAIN noninteractive
+
+name: bread-chisel-releases
+version: "25.10"
+summary: chiselled ubuntu 25.10 + sshd + chisel + tooling spread test-host rock
+description: |
+ Throwaway spread test-host for chisel-releases spread tests: a chiselled
+ (base: bare) Ubuntu 25.10 rock with bash, coreutils, openssh-server, apt, a
+ pam_permit-only auth stack, plus chisel (+ chisel-hacked), docker, and the
+ tooling chisel-releases tests typically need (curl, wget, git, jq, file,
+ sudo, ip).
+
+ INSECURE BY DESIGN. Built for ephemeral spread test containers only; never
+ expose to a network or use in production.
+
+base: bare
+build-base: ubuntu@25.10
+
+platforms:
+ amd64:
+ arm64:
+
+services:
+ sshd:
+ override: replace
+ command: /usr/local/bin/sshd-entry.sh
+ startup: enabled
+
+parts:
+ # --- de-novo copy of the bread base (rocks can't build from rocks), with
+ # the chisel-releases tooling slices folded into the same single cut. ------
+ bread:
+ plugin: nil
+ build-snaps:
+ - chisel
+ build-packages:
+ - git
+ override-build: |
+ set -eu
+
+ releases=$(mktemp -d)
+ git clone --depth 1 --branch ubuntu-25.10 \
+ https://github.com/canonical/chisel-releases.git "$releases"
+
+ # OpenSSH >=9.8 (this release) re-execs sshd-session + sshd-auth per
+ # connection; the openssh-server slice ships only /usr/sbin/sshd, so sshd
+ # exits 255 at startup. Add them to the cloned slice before cutting.
+ # (24.04's OpenSSH 9.6 predates the split and has no such binaries.)
+ sed -i \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-session:' \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-auth:' \
+ "$releases/slices/openssh-server.yaml"
+
+ # bread base slices + chisel-releases tooling slices, one cut.
+ # holes: tree + skopeo have no slice yet -> omitted.
+ bash "$CRAFT_PROJECT_DIR/hack/chisel_cut.sh" "$releases" \
+ base-files_base base-files_etc base-files_var base-files_tmp \
+ base-files_lib base-files_home base-files_release-info base-files_chisel \
+ bash_bins coreutils_bins tar_bins gzip_bins \
+ base-passwd_data login_bins passwd_bins \
+ openssh-server_bins openssh-server_config ca-certificates_data \
+ libpam-modules_libs \
+ apt_apt-get apt_config apt_var apt_default-source-list dpkg_bins \
+ curl_bins wget_bins git_bins git_http-support jq_bins file_bins \
+ sudo_bins sudo_config iproute2_bins
+
+ cd "$CRAFT_PART_INSTALL"
+
+ grep -q '^sshd:' etc/passwd || \
+ echo 'sshd:x:101:65534::/run/sshd:/usr/sbin/nologin' >> etc/passwd
+ hash=$(openssl passwd -6 bread)
+ {
+ printf 'root:%s:20000:0:99999:7:::\n' "$hash"
+ printf 'sshd:!:20000:0:99999:7:::\n'
+ } > etc/shadow
+ chmod 0640 etc/shadow
+
+ mkdir -p etc/ssh
+ cat > etc/ssh/sshd_config <<'EOF'
+ PermitRootLogin yes
+ PasswordAuthentication yes
+ UsePAM yes
+ Subsystem sftp /usr/lib/openssh/sftp-server
+ PidFile /run/sshd.pid
+ EOF
+
+ mkdir -p etc/pam.d
+ printf '%s\n' \
+ 'auth required pam_permit.so' \
+ 'account required pam_permit.so' \
+ 'session required pam_permit.so' \
+ 'password required pam_permit.so' \
+ > etc/pam.d/sshd
+
+ cat > etc/profile <<'EOF'
+ if [ -d /etc/profile.d ]; then
+ for __pf in /etc/profile.d/*.sh; do
+ [ -r "$__pf" ] && . "$__pf"
+ done
+ unset __pf
+ fi
+ EOF
+
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/sshd-entry.sh" usr/local/bin/sshd-entry.sh
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt-get
+ install -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/banner.txt" etc/bread-banner.txt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/bread-warning.sh" etc/profile.d/00-bread-warning.sh
+ mkdir -p root
+ printf '\n# bread-warning\n[ -r /etc/profile.d/00-bread-warning.sh ] && . /etc/profile.d/00-bread-warning.sh\n' \
+ >> root/.bashrc
+
+ # --- chisel + chisel-hacked, built from source (go is build-only) --------
+ chisel:
+ plugin: nil
+ build-snaps:
+ - go
+ build-packages:
+ - git
+ build-environment:
+ - CHISEL_REF: v1.4.1
+ override-build: bash "$CRAFT_PROJECT_DIR/hack/chisel_override_build.sh"
+
+ # --- docker static CLI ---------------------------------------------------
+ docker:
+ plugin: nil
+ build-packages:
+ - curl
+ - ca-certificates
+ build-environment:
+ - DOCKER_VERSION: 29.5.2
+ override-build: bash "$CRAFT_PROJECT_DIR/hack/docker_override_build.sh"
diff --git a/rocks/bread-chisel-releases-26.04/rockcraft.yaml b/rocks/bread-chisel-releases-26.04/rockcraft.yaml
new file mode 100644
index 0000000..741baae
--- /dev/null
+++ b/rocks/bread-chisel-releases-26.04/rockcraft.yaml
@@ -0,0 +1,132 @@
+# spellchecker: ignore rockcraft chiselled coreutils passwd nologin sshd pebble chisel CHISEL GOTOOLCHAIN noninteractive
+
+name: bread-chisel-releases
+version: "26.04"
+summary: chiselled ubuntu 26.04 + sshd + chisel + tooling spread test-host rock
+description: |
+ Throwaway spread test-host for chisel-releases spread tests: a chiselled
+ (base: bare) Ubuntu 26.04 rock with bash, coreutils, openssh-server, apt, a
+ pam_permit-only auth stack, plus chisel (+ chisel-hacked), docker, and the
+ tooling chisel-releases tests typically need (curl, wget, git, jq, file,
+ sudo, ip).
+
+ INSECURE BY DESIGN. Built for ephemeral spread test containers only; never
+ expose to a network or use in production.
+
+base: bare
+build-base: ubuntu@26.04
+
+platforms:
+ amd64:
+ arm64:
+
+services:
+ sshd:
+ override: replace
+ command: /usr/local/bin/sshd-entry.sh
+ startup: enabled
+
+parts:
+ # --- de-novo copy of the bread base (rocks can't build from rocks), with
+ # the chisel-releases tooling slices folded into the same single cut. ------
+ bread:
+ plugin: nil
+ build-snaps:
+ - chisel
+ build-packages:
+ - git
+ override-build: |
+ set -eu
+
+ releases=$(mktemp -d)
+ git clone --depth 1 --branch ubuntu-26.04 \
+ https://github.com/canonical/chisel-releases.git "$releases"
+
+ # OpenSSH >=9.8 (this release) re-execs sshd-session + sshd-auth per
+ # connection; the openssh-server slice ships only /usr/sbin/sshd, so sshd
+ # exits 255 at startup. Add them to the cloned slice before cutting.
+ # (24.04's OpenSSH 9.6 predates the split and has no such binaries.)
+ sed -i \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-session:' \
+ -e '\#^ /usr/sbin/sshd:#a\ /usr/lib/openssh/sshd-auth:' \
+ "$releases/slices/openssh-server.yaml"
+
+ # bread base slices + chisel-releases tooling slices, one cut.
+ # holes: tree + skopeo have no slice yet -> omitted.
+ bash "$CRAFT_PROJECT_DIR/hack/chisel_cut.sh" "$releases" \
+ base-files_base base-files_etc base-files_var base-files_tmp \
+ base-files_lib base-files_home base-files_release-info base-files_chisel \
+ bash_bins coreutils_bins tar_bins gzip_bins \
+ base-passwd_data login_bins passwd_bins \
+ openssh-server_bins openssh-server_config ca-certificates_data \
+ libpam-modules_libs \
+ apt_apt-get apt_config apt_var apt_default-source-list dpkg_bins \
+ curl_bins wget_bins git_bins git_http-support jq_bins file_bins \
+ sudo_bins sudo_config iproute2_bins
+
+ cd "$CRAFT_PART_INSTALL"
+
+ grep -q '^sshd:' etc/passwd || \
+ echo 'sshd:x:101:65534::/run/sshd:/usr/sbin/nologin' >> etc/passwd
+ hash=$(openssl passwd -6 bread)
+ {
+ printf 'root:%s:20000:0:99999:7:::\n' "$hash"
+ printf 'sshd:!:20000:0:99999:7:::\n'
+ } > etc/shadow
+ chmod 0640 etc/shadow
+
+ mkdir -p etc/ssh
+ cat > etc/ssh/sshd_config <<'EOF'
+ PermitRootLogin yes
+ PasswordAuthentication yes
+ UsePAM yes
+ Subsystem sftp /usr/lib/openssh/sftp-server
+ PidFile /run/sshd.pid
+ EOF
+
+ mkdir -p etc/pam.d
+ printf '%s\n' \
+ 'auth required pam_permit.so' \
+ 'account required pam_permit.so' \
+ 'session required pam_permit.so' \
+ 'password required pam_permit.so' \
+ > etc/pam.d/sshd
+
+ cat > etc/profile <<'EOF'
+ if [ -d /etc/profile.d ]; then
+ for __pf in /etc/profile.d/*.sh; do
+ [ -r "$__pf" ] && . "$__pf"
+ done
+ unset __pf
+ fi
+ EOF
+
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/sshd-entry.sh" usr/local/bin/sshd-entry.sh
+ install -D -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt-get
+ install -m0755 "$CRAFT_PROJECT_DIR/hack/lazy-apt.sh" usr/local/bin/apt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/banner.txt" etc/bread-banner.txt
+ install -D -m0644 "$CRAFT_PROJECT_DIR/hack/bread-warning.sh" etc/profile.d/00-bread-warning.sh
+ mkdir -p root
+ printf '\n# bread-warning\n[ -r /etc/profile.d/00-bread-warning.sh ] && . /etc/profile.d/00-bread-warning.sh\n' \
+ >> root/.bashrc
+
+ # --- chisel + chisel-hacked, built from source (go is build-only) --------
+ chisel:
+ plugin: nil
+ build-snaps:
+ - go
+ build-packages:
+ - git
+ build-environment:
+ - CHISEL_REF: v1.4.1
+ override-build: bash "$CRAFT_PROJECT_DIR/hack/chisel_override_build.sh"
+
+ # --- docker static CLI ---------------------------------------------------
+ docker:
+ plugin: nil
+ build-packages:
+ - curl
+ - ca-certificates
+ build-environment:
+ - DOCKER_VERSION: 29.5.2
+ override-build: bash "$CRAFT_PROJECT_DIR/hack/docker_override_build.sh"
diff --git a/rocks/hack/chisel_cut.sh b/rocks/hack/chisel_cut.sh
new file mode 100755
index 0000000..db2af91
--- /dev/null
+++ b/rocks/hack/chisel_cut.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Wrapper around `chisel cut` with retry on known-flaky archive
+# errors. Patterns lifted from canonical/chisel-releases'
+# .github/scripts/install-slices/install_slices.py.
+#
+# Usage: chisel_cut.sh [ ...]
+# Requires:
+# - to be a chisel-releases checkout
+# - $CRAFT_PART_INSTALL to be set (rockcraft env)
+set -e
+
+N_RETRIES=10
+RETRY_DELAY=60
+PATTERNS=(
+ # https://github.com/canonical/chisel-releases/issues/765
+ "cannot fetch from archive"
+ # https://github.com/canonical/chisel-releases/issues/766
+ "cannot talk to archive"
+ # https://github.com/canonical/chisel-releases/issues/768
+ "cannot find archive data"
+ # transient digest mismatch (chisel bug)
+ "expected digest"
+)
+
+[ "$#" -ge 2 ] || { echo "usage: $0 [ ...]" >&2; exit 1; }
+[ -n "${CRAFT_PART_INSTALL:-}" ] || { echo "CRAFT_PART_INSTALL unset" >&2; exit 1; }
+
+release_dir=$1
+shift
+
+for attempt in $(seq 1 "$N_RETRIES"); do
+ err=$(chisel cut \
+ --release "$release_dir" \
+ --root "$CRAFT_PART_INSTALL" \
+ "$@" 2>&1) && rc=0 || rc=$?
+ if [ "$rc" -eq 0 ]; then
+ printf '%s\n' "$err"
+ exit 0
+ fi
+
+ matched=""
+ matched_line=""
+ for p in "${PATTERNS[@]}"; do
+ line=$(printf '%s\n' "$err" | grep -F "$p" | head -n1) || true
+ if [ -n "$line" ]; then matched=$p; matched_line=$line; break; fi
+ done
+
+ if [ -n "$matched" ] && [ "$attempt" -lt "$N_RETRIES" ]; then
+ echo "chisel cut failed (attempt $attempt/$N_RETRIES): $matched. Retrying in ${RETRY_DELAY}s..." >&2
+ echo " > $matched_line" >&2
+ sleep "$RETRY_DELAY"
+ continue
+ fi
+
+ printf '%s\n' "$err" >&2
+ exit "$rc"
+done
diff --git a/rocks/hack/chisel_override_build.sh b/rocks/hack/chisel_override_build.sh
new file mode 100755
index 0000000..d8432fb
--- /dev/null
+++ b/rocks/hack/chisel_override_build.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Build chisel + chisel-hacked from source (canonical/chisel @ $CHISEL_REF),
+# native arch, into $CRAFT_PART_INSTALL/usr/local/bin. Mirrors the root repo's
+# hack/build_binaries.sh, minus the cross-compile -- rockcraft builds natively
+# per platform so GOARCH is just the host arch.
+#
+# Env (from the part's build-environment):
+# CHISEL_REF git ref for canonical/chisel
+set -euo pipefail
+
+: "${CHISEL_REF:?required}"
+
+# go.mod may demand a newer toolchain than the snap ships; let go fetch it.
+export GOTOOLCHAIN=auto
+
+out="$CRAFT_PART_INSTALL/usr/local/bin"
+mkdir -p "$out"
+
+src="$CRAFT_PART_BUILD/chisel-src"
+git clone https://github.com/canonical/chisel "$src"
+cd "$src"
+git checkout "$CHISEL_REF"
+
+# Inject the version string the way upstream's cmd/mkversion.sh would, so
+# `chisel --version` reports the pinned tag instead of "unknown".
+ver=$(git describe --tags --dirty --always)
+echo "==> chisel version: $ver"
+
+CGO_ENABLED=0 go build -trimpath \
+ -ldflags "-s -w -X github.com/canonical/chisel/cmd.Version=$ver" \
+ -o "$out/chisel" ./cmd/chisel
+
+# chisel-hacked: same source + the CHISEL_HACKS patches, -hacked version suffix.
+for p in "$CRAFT_PROJECT_DIR"/patches/chisel/0001-*.patch \
+ "$CRAFT_PROJECT_DIR"/patches/chisel/0002-*.patch \
+ "$CRAFT_PROJECT_DIR"/patches/chisel/0003-*.patch; do
+ echo "==> applying $(basename "$p")"
+ git apply "$p"
+done
+
+CGO_ENABLED=0 go build -trimpath \
+ -ldflags "-s -w -X github.com/canonical/chisel/cmd.Version=${ver}-hacked" \
+ -o "$out/chisel-hacked" ./cmd/chisel
diff --git a/rocks/hack/docker_override_build.sh b/rocks/hack/docker_override_build.sh
new file mode 100755
index 0000000..5e40e1f
--- /dev/null
+++ b/rocks/hack/docker_override_build.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Fetch the upstream docker static CLI for the build-for arch and drop it at
+# /usr/local/bin/docker. Upstream's static build uses a recent go toolchain and
+# so dodges the qemu emulation bug that hits ubuntu apt's docker.io (go 1.24).
+#
+# Env (from the part's build-environment):
+# DOCKER_VERSION docker static release version
+set -euo pipefail
+
+: "${DOCKER_VERSION:?required}"
+
+# docker.com static uses uname-style arch names.
+case "$CRAFT_ARCH_BUILD_FOR" in
+ amd64) docker_arch=x86_64 ;;
+ arm64) docker_arch=aarch64 ;;
+ *) echo "unsupported arch: $CRAFT_ARCH_BUILD_FOR" >&2; exit 1 ;;
+esac
+
+out="$CRAFT_PART_INSTALL/usr/local/bin"
+mkdir -p "$out"
+
+url="https://download.docker.com/linux/static/stable/${docker_arch}/docker-${DOCKER_VERSION}.tgz"
+echo "==> downloading docker $DOCKER_VERSION for $CRAFT_ARCH_BUILD_FOR from $url"
+
+tmp=$(mktemp -d)
+curl -fsSL "$url" | tar -xz -C "$tmp" docker/docker
+install -m0755 "$tmp/docker/docker" "$out/docker"
+rm -rf "$tmp"
diff --git a/rocks/hack/hash_inputs.sh b/rocks/hack/hash_inputs.sh
new file mode 100755
index 0000000..a6c2a51
--- /dev/null
+++ b/rocks/hack/hash_inputs.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Single sha256 over everything that affects this rock's build: the
+# rockcraft.yaml plus all hack/ inputs (and patches/, if present). Drives the
+# .rock.stamp short-circuit in the makefile so `make build` only repacks when a
+# real input changed.
+#
+# Stdout: hex digest only.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+# shellcheck disable=SC2046 # word-splitting of find output is intended here.
+sha256sum \
+ rockcraft.yaml \
+ $(find hack patches -type f 2>/dev/null | sort) \
+ | sha256sum | cut -d' ' -f1
diff --git a/rocks/hack/sshd-entry.sh b/rocks/hack/sshd-entry.sh
new file mode 100755
index 0000000..080d94c
--- /dev/null
+++ b/rocks/hack/sshd-entry.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# pebble service command for sshd in the bread rock.
+#
+# host keys are generated here on first boot rather than baked into the rock,
+# so throwaway test containers don't all share the same keys. also ensures the
+# privsep runtime dir exists. then execs sshd in the foreground (pebble manages
+# the process).
+set -e
+
+mkdir -p /run/sshd
+ssh-keygen -A
+
+exec /usr/sbin/sshd -D -e
diff --git a/rocks/makefile b/rocks/makefile
new file mode 100644
index 0000000..80e9184
--- /dev/null
+++ b/rocks/makefile
@@ -0,0 +1,82 @@
+.DEFAULT_GOAL := help
+
+DOCKER ?= docker
+
+# Rock dirs = every subdir with a rockcraft.yaml (tests/ has none).
+DIRS := $(patsubst %/rockcraft.yaml,%,$(wildcard */rockcraft.yaml))
+
+# Native arch for the default `test` filter (uname -m -> docker/spread arch).
+# Accept both linux (x86_64/aarch64) and macos/docker (amd64/arm64) spellings.
+UNAME_M := $(shell uname -m)
+ifeq ($(filter $(UNAME_M),x86_64 amd64),$(UNAME_M))
+ NATIVE_ARCH := amd64
+else ifeq ($(filter $(UNAME_M),aarch64 arm64),$(UNAME_M))
+ NATIVE_ARCH := arm64
+else
+ $(error unsupported host arch: $(UNAME_M))
+endif
+
+# Default spread filter: every rock dir on the native arch.
+SPREAD_FILTER ?= $(addprefix rock:,$(addsuffix -$(NATIVE_ARCH),$(DIRS)))
+
+.PHONY: help
+help: ## Show this help
+ @grep -E '^[a-zA-Z_-]+%?:.*?## .*$$' $(MAKEFILE_LIST) | \
+ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-18s\033[0m %s\n", $$1, $$2}'
+ @echo ""
+ @echo "rock dirs: $(DIRS)"
+
+# Pack one rock dir, e.g. `make build-bread-24.04`. Syncs the shared
+# hack/patches first, then short-circuits on a stamp of all build inputs so it
+# only repacks on a real change. rockcraft names the artefact
+# __.rock (underscores); the predictable .rock has none,
+# so `*_*.rock` only ever matches the freshly-packed file.
+.PHONY: build-%
+build-%: ## Pack one rock (make build-)
+ @ruby sync_shared.rb $*
+ @cd $* && \
+ new=$$(hack/hash_inputs.sh) ; cur=$$(cat .rock.stamp 2>/dev/null || true) ; \
+ if [ "$$new" = "$$cur" ] && [ -f $*.rock ]; then \
+ echo "==> $* up-to-date" ; \
+ else \
+ echo "==> packing $*" ; \
+ rockcraft pack --verbosity=brief && mv *_*.rock $*.rock && echo "$$new" > .rock.stamp ; \
+ fi
+
+.PHONY: build-all
+build-all: $(addprefix build-,$(DIRS)) ## Pack every rock (narrow via DIRS="bread-24.04 ...")
+
+.PHONY: load
+load: ## Load every built rock into the local daemon as spread-rock-:latest
+ @for d in $(DIRS); do \
+ f="$$d/$$d.rock" ; \
+ [ -f "$$f" ] || { echo "skip $$d (no $$f)"; continue; } ; \
+ echo "==> load $$f -> spread-rock-$$d:latest" ; \
+ skopeo --insecure-policy copy "oci-archive:$$f" "docker-daemon:spread-rock-$$d:latest" ; \
+ done
+
+.PHONY: test
+test: build-all load ## Build + load all rocks, then run the rock suite (native arch)
+ cd ../tests && spread $(SPREAD_FILTER)
+
+.PHONY: spread-only
+spread-only: load ## Load prebuilt rocks + run the rock suite, no packing (for ci where rocks are downloaded)
+ cd ../tests && spread $(SPREAD_FILTER)
+
+.PHONY: size
+size: ## Show packed rock sizes
+ @for d in $(DIRS); do [ -f $$d/$$d.rock ] && printf '%-32s %s\n' "$$d" "$$(du -h $$d/$$d.rock | cut -f1)" || echo "$$d (not built)" ; done
+
+.PHONY: ls-rock-%
+ls-rock-%: ## List a rock's filesystem (make ls-rock-)
+ @tag=ls-$*-$$$$ ; \
+ skopeo --insecure-policy copy oci-archive:$*/$*.rock docker-daemon:$$tag:latest > /dev/null ; \
+ cid=$$($(DOCKER) create $$tag:latest) ; \
+ $(DOCKER) export $$cid | tar tv ; \
+ $(DOCKER) rm $$cid > /dev/null ; \
+ $(DOCKER) rmi $$tag:latest > /dev/null
+
+.PHONY: clean
+clean: ## Remove packed rocks, stamps, synced hack/patches + loaded tags
+ @for d in $(DIRS); do rm -f $$d/$$d.rock $$d/.rock.stamp ; rm -rf $$d/hack $$d/patches ; done
+ -@for d in $(DIRS); do $(DOCKER) rmi -f "spread-rock-$$d:latest" 2>/dev/null ; done
diff --git a/rocks/sync_shared.rb b/rocks/sync_shared.rb
new file mode 100644
index 0000000..e3cffef
--- /dev/null
+++ b/rocks/sync_shared.rb
@@ -0,0 +1,52 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Populate a rock dir's hack/ (and patches/ for the chisel-releases flavour)
+# from the shared sources, so each rock dir is hand-maintained in one place.
+#
+# rockcraft only copies the project dir (the one with rockcraft.yaml) into the
+# build instance, so each rock needs its own physical copy of the shared files
+# at pack time. The per-dir copies are gitignored and regenerated from here.
+#
+# Sources:
+# - repo-root hack/ + patches/chisel -- shared with the docker-image build,
+# sourced from there rather than duplicated.
+# - rocks/hack -- rock-specific helpers (incl. a rock-flavoured hash_inputs.sh
+# that differs from the root one).
+#
+# Usage: ruby sync_shared.rb
+
+require "fileutils"
+
+ROOT = __dir__
+REPO_ROOT = File.expand_path("..", ROOT)
+
+# Identical to the repo-root copies -> single source there.
+ROOT_HACK = %w[banner.txt bread-warning.sh lazy-apt.sh].freeze
+# Rock-specific (hash_inputs.sh here differs from the root one; the rest are
+# rock-only). The chisel-releases flavour also builds chisel + docker from
+# source, so it gets those two extra build scripts + the patches.
+ROCK_HACK = %w[sshd-entry.sh hash_inputs.sh chisel_cut.sh].freeze
+CR_ONLY = %w[chisel_override_build.sh docker_override_build.sh].freeze
+
+dir = ARGV[0] or abort "usage: ruby sync_shared.rb "
+dest = File.join(ROOT, dir)
+abort "no such rock dir: #{dir}" unless File.directory?(dest)
+
+cr = dir.include?("chisel-releases")
+
+FileUtils.mkdir_p(File.join(dest, "hack"))
+ROOT_HACK.each do |f|
+ FileUtils.cp(File.join(REPO_ROOT, "hack", f), File.join(dest, "hack", f), preserve: true)
+end
+(ROCK_HACK + (cr ? CR_ONLY : [])).each do |f|
+ FileUtils.cp(File.join(ROOT, "hack", f), File.join(dest, "hack", f), preserve: true)
+end
+
+if cr
+ chisel = File.join(dest, "patches", "chisel")
+ FileUtils.mkdir_p(chisel)
+ Dir[File.join(REPO_ROOT, "patches", "chisel", "*.patch")].each do |p|
+ FileUtils.cp(p, File.join(chisel, File.basename(p)), preserve: true)
+ end
+end
diff --git a/tests/contract-rocks/run/task.yaml b/tests/contract-rocks/run/task.yaml
new file mode 100644
index 0000000..168f7e8
--- /dev/null
+++ b/tests/contract-rocks/run/task.yaml
@@ -0,0 +1,67 @@
+summary: rock contract -- version/arch + bread bits, plus chisel/tooling for the cr flavour
+
+execute: |
+ set -e
+
+ # grep is NOT in the rock (coreutils_bins doesn't ship it), so this task
+ # uses only shell builtins + coreutils -- no grep/sed.
+ name="$SPREAD_SYSTEM"
+ arch="${name##*-}"
+ rest="${name%-*}"
+ expected_ver="${rest##*-}"
+ flavour="${rest%-*}"
+
+ # --- ubuntu version (from base-files_release-info) matches the system name.
+ actual_ver=$(. /etc/os-release && echo "$VERSION_ID")
+ if [ "$expected_ver" != "$actual_ver" ]; then
+ echo "ver mismatch: expected=$expected_ver got=$actual_ver"
+ exit 1
+ fi
+
+ # --- arch matches (uname -m -> docker arch name).
+ case "$(uname -m)" in
+ x86_64) actual_arch=amd64 ;;
+ aarch64) actual_arch=arm64 ;;
+ *) actual_arch=$(uname -m) ;;
+ esac
+ if [ "$arch" != "$actual_arch" ]; then
+ echo "arch mismatch: expected=$arch got=$actual_arch"
+ exit 1
+ fi
+
+ # --- shell + core utils present.
+ command -v bash >/dev/null || { echo "missing bash"; exit 1; }
+ command -v ls >/dev/null || { echo "missing coreutils (ls)"; exit 1; }
+
+ # --- bread-warning banner: files installed + the script prints when sourced.
+ # spread's (non-tty) session shell already sourced /etc/profile -> profile.d,
+ # whose silent path exports BREAD_BANNER_SHOWN=1 -- so clear it here, else the
+ # dedupe guard makes the re-source a no-op.
+ test -r /etc/profile.d/00-bread-warning.sh || {
+ echo "missing /etc/profile.d/00-bread-warning.sh"; exit 1; }
+ test -r /etc/bread-banner.txt || { echo "missing /etc/bread-banner.txt"; exit 1; }
+ out=$(BREAD_BANNER_SHOWN= BREAD_BANNER_FORCE=1 bash -c '. /etc/profile.d/00-bread-warning.sh' 2>&1)
+ case "$out" in
+ *"INSECURE TEST IMAGE"*) : ;;
+ *) echo "banner did not print when sourced"; echo "--- $out ---"; exit 1 ;;
+ esac
+
+ # --- apt: update resolves, and the lazy-apt shim self-replaces with a symlink.
+ apt-get update
+ [ -L /usr/local/bin/apt-get ] || { echo "lazy-apt shim did not self-replace"; exit 1; }
+
+ # --- chisel-releases flavour extras.
+ case "$flavour" in
+ *chisel*)
+ cv=$(chisel --version 2>&1)
+ case "$cv" in *v1.4.1*) : ;; *) echo "chisel ver wrong: $cv"; exit 1 ;; esac
+ chv=$(chisel-hacked --version 2>&1)
+ case "$chv" in *v1.4.1-hacked*) : ;; *) echo "chisel-hacked ver wrong: $chv"; exit 1 ;; esac
+ # tree + skopeo are holes (no slice yet) -> not checked.
+ for cmd in curl wget git jq file sudo ip docker; do
+ command -v "$cmd" >/dev/null || { echo "missing command: $cmd"; exit 1; }
+ done
+ ;;
+ esac
+
+ echo "ok: $SPREAD_SYSTEM"
diff --git a/tests/rock-allocate.sh b/tests/rock-allocate.sh
new file mode 100755
index 0000000..20909dc
--- /dev/null
+++ b/tests/rock-allocate.sh
@@ -0,0 +1,66 @@
+#spellchecker: ignore noninteractive
+
+set -e
+
+# System name encodes flavour, version + arch, e.g.
+# bread-24.04-amd64 -> dir=bread-24.04
+# bread-chisel-releases-26.04-arm64 -> dir=bread-chisel-releases-26.04
+# Parse from the right so the hyphens in bread-chisel-releases don't trip us up.
+name="$SPREAD_SYSTEM"
+arch="${name##*-}"
+rest="${name%-*}"
+ver="${rest##*-}"
+dir="${rest%-*}-$ver"
+
+# The rock is loaded into the local daemon (by `make load` / the ci test job)
+# under this tag before spread runs -- so allocate just `docker run`s it.
+tag="spread-rock-$dir:latest"
+echo "system: $name -> image: $tag"
+
+# Unique container name per worker (flock counter pattern, cf. charmcraft).
+sleep 0.$RANDOM
+export counter_file=".spread-worker-num"
+instance_num=$(
+ flock -x $counter_file bash -c '
+ [ -s $counter_file ] || echo 0 > $counter_file
+ num=$(< $counter_file) && echo $num
+ echo $(( $num + 1 )) > $counter_file'
+)
+container_name="${name}-${instance_num}"
+echo "container: $container_name"
+
+docker rm -f "$container_name" 2>/dev/null || true
+
+# The rock runs pebble as pid 1, which starts the enabled sshd service.
+docker run \
+ --rm \
+ --platform "linux/$arch" \
+ --name "$container_name" \
+ -d "$tag"
+
+ip=$(docker inspect "$container_name" --format '{{.NetworkSettings.Networks.bridge.IPAddress}}')
+
+# Bounded wait for sshd to actually accept TCP on :22 (pgrep is not enough -- a
+# process can exist without listening). On timeout, dump pebble/sshd output so
+# ci shows WHY sshd isn't listening, then fail fast instead of letting spread
+# spin to the kill-timeout.
+ready=
+for _ in $(seq 1 60); do
+ if timeout 1 bash -c "echo > /dev/tcp/$ip/22" 2>/dev/null; then
+ ready=1
+ break
+ fi
+ sleep 1
+done
+
+if [ -z "$ready" ]; then
+ echo "ERROR: sshd not listening on $container_name ($ip:22) after 60s" >&2
+ echo "--- docker logs $container_name ---" >&2
+ docker logs "$container_name" >&2 2>&1 || true
+ echo "--- pebble services + sshd -t + sshd bits ---" >&2
+ docker exec "$container_name" sh -c 'pgrep -a sshd; /usr/sbin/sshd -t; ls -l /usr/sbin/sshd /usr/lib/openssh /run/sshd /etc/ssh' >&2 2>&1 || true
+ docker rm -f "$container_name" >/dev/null 2>&1 || true
+ exit 1
+fi
+
+ADDRESS "$ip"
diff --git a/tests/rock-discard.sh b/tests/rock-discard.sh
new file mode 100755
index 0000000..d9342b9
--- /dev/null
+++ b/tests/rock-discard.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+set -e
+
+echo "discarding container for system: $SPREAD_SYSTEM_ADDRESS"
+
+# Can't filter by IP directly; find the container by inspecting all of them.
+container_name=""
+for cid in $(docker ps -a --filter "network=bridge" --format '{{.ID}}'); do
+ cname=$(docker inspect "$cid" --format '{{.Name}}' | sed 's/^\/\(.*\)/\1/')
+ cip=$(docker inspect "$cid" --format '{{.NetworkSettings.Networks.bridge.IPAddress}}' || echo "")
+ if [ "$cip" == "$SPREAD_SYSTEM_ADDRESS" ]; then
+ container_name="$cname"
+ break
+ fi
+done
+
+if [ -n "$container_name" ]; then
+ echo "removing container: $container_name"
+ docker rm -f "$container_name" 2>/dev/null || true
+else
+ echo "no container found with IP address: $SPREAD_SYSTEM_ADDRESS"
+ exit 1
+fi
diff --git a/tests/spread.yaml b/tests/spread.yaml
index 587b189..4ac1505 100644
--- a/tests/spread.yaml
+++ b/tests/spread.yaml
@@ -93,9 +93,37 @@ backends:
password: bread
workers: 2
+ # The rock suite (contract-rocks) runs against this backend instead: it
+ # docker-runs the loaded rock directly (the rock is its own sshd host), no
+ # bread-test outer container. Coupled into this one spread.yaml but kept a
+ # separate backend + suite; the docker / rocks ci workflows each filter to
+ # their own backend (outer: vs rock:).
+ rock:
+ type: adhoc
+ allocate: source rock-allocate.sh
+ discard: source rock-discard.sh
+ systems:
+ - bread-24.04-amd64: {username: root, password: bread}
+ - bread-24.04-arm64: {username: root, password: bread}
+ - bread-25.10-amd64: {username: root, password: bread}
+ - bread-25.10-arm64: {username: root, password: bread}
+ - bread-26.04-amd64: {username: root, password: bread}
+ - bread-26.04-arm64: {username: root, password: bread}
+ - bread-chisel-releases-24.04-amd64: {username: root, password: bread}
+ - bread-chisel-releases-24.04-arm64: {username: root, password: bread}
+ - bread-chisel-releases-25.10-amd64: {username: root, password: bread}
+ - bread-chisel-releases-25.10-arm64: {username: root, password: bread}
+ - bread-chisel-releases-26.04-amd64: {username: root, password: bread}
+ - bread-chisel-releases-26.04-arm64: {username: root, password: bread}
+
suites:
+ # spread has no wildcards in `systems:`, so scope each suite by explicit
+ # name: the bread-image suites whitelist the outer systems; the rock suite
+ # excludes them (leaving the rock backend's systems). this keeps a bare
+ # `spread` from cross-running a suite on the wrong backend.
contract-bread/:
summary: bread image contract -- exercises inlined/bread-.yaml
+ systems: [ubuntu-26.04-amd64, ubuntu-26.04-arm64]
# task.yaml here will run inner spread against each bread inlined yaml.
# Variants below pick which version to exercise.
environment:
@@ -106,8 +134,13 @@ suites:
contract-bread-chisel-releases/:
summary: bread-chisel-releases image contract
+ systems: [ubuntu-26.04-amd64, ubuntu-26.04-arm64]
environment:
INLINED/v24_04: $REPO/inlined/bread-chisel-releases-24.04.yaml
INLINED/v25_10: $REPO/inlined/bread-chisel-releases-25.10.yaml
INLINED/v26_04: $REPO/inlined/bread-chisel-releases-26.04.yaml
INLINED/v26_10: $REPO/inlined/bread-chisel-releases-26.10.yaml
+
+ contract-rocks/:
+ summary: rock contract -- docker-runs each loaded rock + checks it
+ systems: [-ubuntu-26.04-amd64, -ubuntu-26.04-arm64]