Skip to content
This repository was archived by the owner on Aug 22, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Signed OCI exports**: Drop source transport signatures when exporting a
composefs image to a local OCI layout, which cannot store them.
- **btrfs subvolume install: hostname/flatpak post-steps failed**: On btrfs
installs with subvolumes, retagging the root partition for GPT auto-discovery
remounted the root partition without `subvol=@`, exposing the btrfs top-level
instead of the `@` subvolume. Post-install steps that write through
`state/deploy` (hostname, system Flatpaks) then failed with
"finding composefs deploy etc: reading composefs deploy base …/state/deploy:
no such file or directory", aborting the install at 99%. The remount now
reuses the `subvol=@,compress=zstd:1` options via the new
`disk.BtrfsRootMountOpts` constant.
- **OCI layout for non-composefs installs**: Non-composefs images (bluefin, lts,
lts-hwe) now export to an OCI layout at scratch and use `--source-imgref oci:...`
for `bootc install to-filesystem`. The previous VFS squash path corrupted ostree
Expand Down
8 changes: 6 additions & 2 deletions fisherman/cmd/fisherman/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -922,8 +922,12 @@ func main() {
if err := os.MkdirAll(activeTargetMount, 0o755); err != nil {
fatal("recreating target mountpoint before remount: %v", err)
}
rootPart := disk.PartName(r.Disk, 2)
if err := disk.Mount(rootPart, activeTargetMount, ""); err != nil {
// Preserve the btrfs subvolume layout: SetupBtrfsSubvolumes mounted the
// target with subvol=@, and post-install steps (hostname, flatpaks) write
// through state/deploy inside that subvolume. Remounting without subvol=@
// would expose the btrfs top-level instead, where state/deploy does not
// exist, causing "finding composefs deploy etc" to fail.
if err := disk.RemountRoot(r.Disk, 2, activeTargetMount, r.BtrfsSubvolumes); err != nil {
fatal("remounting root partition after retagging: %v", err)
}
// Remount EFI so that Plymouth/LUKS arg writes land on the real ESP
Expand Down
25 changes: 24 additions & 1 deletion fisherman/internal/disk/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import (
"github.com/tuna-os/fisherman/internal/runner"
)

// BtrfsRootMountOpts are the mount options used for the root btrfs subvolume
// layout (@, @home, @snapshots). The installed system's state/deploy tree lives
// inside the @ subvolume, so any (re)mount of the root partition that post-install
// steps write through must use these options; a bare mount would expose the
// btrfs top-level instead, where state/deploy does not exist.
const BtrfsRootMountOpts = "subvol=@,compress=zstd:1"

// FormatEFI formats a partition as FAT32 for use as the EFI System Partition.
func FormatEFI(part string) error {
return runner.Run("mkfs.fat", "-F32", "-n", "EFI-SYSTEM", part)
Expand Down Expand Up @@ -89,6 +96,22 @@ func Mount(dev, target, opts string) error {
return nil
}

// RemountRoot remounts the root partition (partition partNum of diskDev) at
// target after an operation that dropped the mount, such as retagging the root
// GPT type for systemd-boot GPT auto-discovery.
//
// When the install uses btrfs subvolumes, the root must be remounted with
// subvol=@ so that post-install writes land inside the @ subvolume where the
// composefs deployment (state/deploy) lives. A bare remount would expose the
// btrfs top-level instead, where state/deploy does not exist.
func RemountRoot(diskDev string, partNum int, target string, btrfsSubvols bool) error {
opts := ""
if btrfsSubvols {
opts = BtrfsRootMountOpts
}
return Mount(PartName(diskDev, partNum), target, opts)
}

// MountTmpfs mounts a tmpfs of the given size (e.g. "4G") at path, creating
// the directory if needed.
func MountTmpfs(path, size string) error {
Expand Down Expand Up @@ -138,7 +161,7 @@ func SetupBtrfsSubvolumes(dev, target string) error {
}

// Remount with the @ subvolume and transparent compression.
return Mount(dev, target, "subvol=@,compress=zstd:1")
return Mount(dev, target, BtrfsRootMountOpts)
}

// FormatBoot formats a partition as ext4 for use as /boot.
Expand Down
58 changes: 58 additions & 0 deletions fisherman/internal/disk/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,64 @@ func TestSetupBtrfsSubvolumes(t *testing.T) {
}
}

// ── RemountRoot ───────────────────────────────────────────────────────────

// TestRemountRoot is a regression test for the btrfs-subvolume install that
// aborted at 99% ("finding composefs deploy etc: reading composefs deploy base
// …/state/deploy: no such file or directory"). After retagging the root GPT
// type for systemd-boot GPT auto-discovery, the root partition is remounted;
// for btrfs subvolume installs it MUST be remounted with subvol=@ so post-install
// writes reach the @ subvolume where the composefs deployment lives. A bare
// remount exposes the btrfs top-level, where state/deploy does not exist.
func TestRemountRoot(t *testing.T) {
tests := []struct {
name string
btrfsSubvols bool
wantOpts bool // whether -o <opts> should be present
}{
{name: "btrfs subvolumes preserves subvol=@", btrfsSubvols: true, wantOpts: true},
{name: "non-subvolume remounts bare", btrfsSubvols: false, wantOpts: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := setupRecorder(t)
if err := disk.RemountRoot("/dev/nvme0n1", 2, "/mnt/fisherman-target", tt.btrfsSubvols); err != nil {
t.Fatalf("RemountRoot: %v", err)
}
if len(rec.calls) != 1 {
t.Fatalf("expected 1 call, got %d: %+v", len(rec.calls), rec.calls)
}
c := rec.calls[0]
if c.name != "mount" {
t.Errorf("name = %q, want mount", c.name)
}
if c.args[len(c.args)-2] != "/dev/nvme0n1p2" {
t.Errorf("device arg = %q, want /dev/nvme0n1p2", c.args[len(c.args)-2])
}
if c.args[len(c.args)-1] != "/mnt/fisherman-target" {
t.Errorf("target arg = %q, want /mnt/fisherman-target", c.args[len(c.args)-1])
}
opts := ""
for i, arg := range c.args {
if arg == "-o" && i+1 < len(c.args) {
opts = c.args[i+1]
break
}
}
if tt.wantOpts {
if !strings.Contains(opts, "subvol=@") {
t.Errorf("btrfs remount opts %q missing subvol=@", opts)
}
if !strings.Contains(opts, "compress=zstd:1") {
t.Errorf("btrfs remount opts %q missing compress=zstd:1", opts)
}
} else if opts != "" {
t.Errorf("non-subvolume remount should have no -o opts, got %q", opts)
}
})
}
}

// ── BindMount / scratch space ─────────────────────────────────────────────

// TestBindMount verifies that BindMount calls mount --bind with the correct args.
Expand Down
Loading