From ee95c0234dbd441ab354d3c6f9b62961552edab5 Mon Sep 17 00:00:00 2001 From: eriknordmark Date: Sat, 30 May 2026 00:50:49 +0200 Subject: [PATCH] Add --preserve-numbers to keep partition numbers When a partition is grown it is relocated into free space and given a new slot, so its partition number changes (e.g. IMGA moves from /dev/sda2 to /dev/sda5). Boot loaders and other consumers that hard-code a partition number rather than locating it by label or UUID then fail to find it. Add an optional --preserve-numbers mode. After the data has been copied and the original identity swapped onto the relocated partition, the new renumberPartitions step drops the vacated original slot and reassigns the relocated partition's GPT index back to the original number, in a single table write. The partition keeps its new on-disk offset, so the GPT entries are no longer in disk-offset order; this is permitted by the GPT spec and is invisible to consumers that reference a partition by number. In-place shrinks already preserve their number, so only the grow/relocate path is affected. Signed-off-by: eriknordmark Co-Authored-By: Claude Opus 4.8 --- cmd/resizer/main.go | 4 +- resize.go | 81 +++++++++++++++++++++++++++++++--- resize_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++ run.go | 6 ++- run_test.go | 25 ++++++++++- 5 files changed, 211 insertions(+), 10 deletions(-) diff --git a/cmd/resizer/main.go b/cmd/resizer/main.go index 049463f..39056e4 100644 --- a/cmd/resizer/main.go +++ b/cmd/resizer/main.go @@ -16,6 +16,7 @@ var rootCmd = func() *cobra.Command { growPartitions []string fixErrors bool dryRun bool + preserveNumbers bool ) cmd := &cobra.Command{ Use: "resizer", @@ -71,7 +72,7 @@ var rootCmd = func() *cobra.Command { if len(args) > 0 { disk = args[0] } - if err := resizer.Run(disk, &shrinkPartitionParsed, growPartitionsParsed, fixErrors, dryRun); err != nil { + if err := resizer.Run(disk, &shrinkPartitionParsed, growPartitionsParsed, fixErrors, dryRun, preserveNumbers); err != nil { log.Fatalf("Resize operation failed: %v", err) } }, @@ -80,6 +81,7 @@ var rootCmd = func() *cobra.Command { cmd.Flags().StringSliceVar(&growPartitions, "grow-partition", []string{}, "Partitions to grow, along with their desired sizes, in format identifier:partition:size, see help (e.g. name:sda1:20G or label:EFI System:100M)") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "If set, will only simulate the resize operations without making any changes") cmd.Flags().BoolVar(&fixErrors, "fix-errors", false, "If set, will attempt to fix any ext4 filesystem errors found during fsck before shrinking") + cmd.Flags().BoolVar(&preserveNumbers, "preserve-numbers", false, "If set, a grown partition that is relocated is renumbered back to its original partition number, so labels keep their original partition numbers (e.g. /dev/sda2)") return cmd } diff --git a/resize.go b/resize.go index 9e141c1..040fcaf 100644 --- a/resize.go +++ b/resize.go @@ -16,8 +16,11 @@ type copyData struct { err error } -// resize performs the actual resize operations on the given disk -func resize(d *disk.Disk, resizes []partitionResizeTarget, fixErrors bool) error { +// resize performs the actual resize operations on the given disk. +// When preserveNumbers is set, a relocated partition is renumbered back to its +// original partition number after the copy, so that consumers referencing a +// partition by number (e.g. boot loaders) continue to find it. +func resize(d *disk.Disk, resizes []partitionResizeTarget, fixErrors, preserveNumbers bool) error { // do any shrinks first // this is idempotent. If I have a 500MB partition with a 500MB filesystem, // and shrink it to 400MB. If I stop, and then run it again, it will just say @@ -54,9 +57,16 @@ func resize(d *disk.Disk, resizes []partitionResizeTarget, fixErrors bool) error return err } - // remove the old partitions - if err := removePartitions(d, resizes); err != nil { - return err + // remove the old partitions, optionally renumbering the relocated partitions + // back to their original partition numbers + if preserveNumbers { + if err := renumberPartitions(d, resizes); err != nil { + return err + } + } else { + if err := removePartitions(d, resizes); err != nil { + return err + } } return nil @@ -219,6 +229,67 @@ func removePartitions(d *disk.Disk, resizes []partitionResizeTarget) error { return nil } +// renumberPartitions removes the original partitions and reassigns each relocated +// target partition's GPT slot index to the original partition's number, so the +// resized partition keeps the same partition number it had before. This is the +// preserve-numbers counterpart to removePartitions, and must run after the data +// has been copied and the identities swapped onto the target partitions. +// +// The renumbered entry stays at its new on-disk offset, so the resulting GPT entries +// are no longer in disk-offset order. That is permitted by the GPT specification and +// is invisible to consumers that locate a partition by its number (e.g. a boot loader +// referencing (hd0,gptN)); only tools that expect entries sorted by offset will note it. +func renumberPartitions(d *disk.Disk, resizes []partitionResizeTarget) error { + tableRaw, err := d.GetPartitionTable() + if err != nil { + return err + } + table, ok := tableRaw.(*gpt.Table) + if !ok { + return fmt.Errorf("unsupported partition table type, only GPT is supported") + } + // map partition number -> position in the slice, captured before any mutation so + // that the lookups below are unaffected by the index reassignments we make. + indexToPosition := make(map[int]int) + for i, p := range table.Partitions { + indexToPosition[p.Index] = i + } + // slice positions of the original partitions to drop, keyed by position rather + // than partition number: once we reassign a target's Index to the original number, + // keying removal on Index would also match (and wrongly drop) the renumbered target. + removePositions := make(map[int]bool) + for _, r := range resizes { + if r.original.number == r.target.number { + log.Printf("partition %d %s: no change in partition number, no need to renumber", r.original.number, r.original.label) + continue + } + origPos, ok := indexToPosition[r.original.number] + if !ok { + return fmt.Errorf("original partition %d not found in partition table", r.original.number) + } + targetPos, ok := indexToPosition[r.target.number] + if !ok { + return fmt.Errorf("target partition %d not found in partition table", r.target.number) + } + log.Printf("renumbering partition %d -> %d (label %s) and removing original slot", r.target.number, r.original.number, r.original.label) + table.Partitions[targetPos].Index = r.original.number + removePositions[origPos] = true + } + // rebuild the slice, dropping the vacated original slots so their numbers are free + partitions := make([]*gpt.Partition, 0, len(table.Partitions)) + for i, p := range table.Partitions { + if removePositions[i] { + continue + } + partitions = append(partitions, p) + } + table.Partitions = partitions + if err := d.Partition(table); err != nil { + return fmt.Errorf("failed to write renumbered partition table: %v", err) + } + return nil +} + // swapPartitions swaps the labels, Type GUIDs, and UUIDs of the original and target partitions, // as well as any attributes flags. func swapPartitions(d *disk.Disk, resizes []partitionResizeTarget) error { diff --git a/resize_test.go b/resize_test.go index 64bab22..2aed6b8 100644 --- a/resize_test.go +++ b/resize_test.go @@ -236,6 +236,111 @@ func TestRemovePartitions(t *testing.T) { } } +func TestRenumberPartitions(t *testing.T) { + // Model the state after createPartitions+copy+swap for two grown partitions: + // originals 2 and 3 (now carrying throwaway identities) plus their relocated + // copies in slots 5 and 6 (carrying the real labels). renumberPartitions must + // drop the originals and move the relocated copies back to numbers 2 and 3. + workDir := t.TempDir() + f, err := os.CreateTemp(workDir, "disk.img") + if err != nil { + t.Fatalf("failed to create temp disk image: %v", err) + } + if err := os.Truncate(f.Name(), 1*GB); err != nil { + t.Fatalf("failed to truncate disk image: %v", err) + } + defer func() { _ = f.Close() }() + + backend := file.New(f, false) + d, err := diskfs.OpenBackend(backend, diskfs.WithOpenMode(diskfs.ReadWrite)) + if err != nil { + t.Fatalf("failed to open disk: %v", err) + } + var offset uint64 = 2048 + table := &gpt.Table{ + Partitions: []*gpt.Partition{ + {Index: 1, Start: offset, Size: 36 * MB, Type: gpt.LinuxFilesystem, Name: "part1"}, + // originals, to be dropped + {Index: 2, Start: offset + 36*MB, Size: 100 * MB, Type: gpt.LinuxFilesystem, Name: "IMGA_old"}, + {Index: 3, Start: offset + 36*MB + 100*MB, Size: 50 * MB, Type: gpt.LinuxFilesystem, Name: "DATA_old"}, + {Index: 4, Start: offset + 36*MB + 100*MB + 50*MB, Size: 36 * MB, Type: gpt.LinuxFilesystem, Name: "part4"}, + // relocated copies carrying the real identities, to be renumbered to 2 and 3 + {Index: 5, Start: offset + 36*MB + 100*MB + 50*MB + 36*MB, Size: 300 * MB, Type: gpt.LinuxFilesystem, Name: "IMGA"}, + {Index: 6, Start: offset + 36*MB + 100*MB + 50*MB + 36*MB + 300*MB, Size: 200 * MB, Type: gpt.LinuxFilesystem, Name: "DATA"}, + }, + } + if err := d.Partition(table); err != nil { + t.Fatalf("failed to write partition table: %v", err) + } + // capture the relocated copies' on-disk starts so we can confirm renumbering keeps + // the data in place and only changes the slot number + wantStart := make(map[string]uint64) + for _, p := range table.Partitions { + wantStart[p.Name] = p.Start + } + + resizes := []partitionResizeTarget{ + { + original: partitionData{number: 2, label: "IMGA"}, + target: partitionData{number: 5, label: "IMGA"}, + }, + { + original: partitionData{number: 3, label: "DATA"}, + target: partitionData{number: 6, label: "DATA"}, + }, + } + + if err := renumberPartitions(d, resizes); err != nil { + t.Fatalf("renumberPartitions failed: %v", err) + } + + tableRaw, err := d.GetPartitionTable() + if err != nil { + t.Fatalf("failed to get partition table: %v", err) + } + newTable, ok := tableRaw.(*gpt.Table) + if !ok { + t.Fatalf("unsupported partition table type, only GPT is supported") + } + + byIndex := make(map[int]*gpt.Partition) + for _, p := range newTable.Partitions { + if p.Type == gpt.Unused { + continue + } + byIndex[p.Index] = p + } + + // originals are gone, relocated copies now own numbers 2 and 3 + wantByNumber := map[int]string{1: "part1", 2: "IMGA", 3: "DATA", 4: "part4"} + if len(byIndex) != len(wantByNumber) { + t.Fatalf("expected %d partitions after renumber, got %d", len(wantByNumber), len(byIndex)) + } + for number, name := range wantByNumber { + p, ok := byIndex[number] + if !ok { + t.Fatalf("expected partition number %d (%s) to exist after renumber", number, name) + } + if p.Name != name { + t.Errorf("partition %d: expected label %q, got %q", number, name, p.Name) + } + } + // the renumbered partitions must keep the relocated copies' on-disk location + if got := byIndex[2].Start; got != wantStart["IMGA"] { + t.Errorf("renumbered IMGA start moved: expected %d, got %d", wantStart["IMGA"], got) + } + if got := byIndex[3].Start; got != wantStart["DATA"] { + t.Errorf("renumbered DATA start moved: expected %d, got %d", wantStart["DATA"], got) + } + // the old high-numbered slots must be free + if _, ok := byIndex[5]; ok { + t.Errorf("slot 5 should have been vacated by renumbering") + } + if _, ok := byIndex[6]; ok { + t.Errorf("slot 6 should have been vacated by renumbering") + } +} + func TestCopyFilesystems(t *testing.T) { // create a duplicate disk with a partition with the specified filesystem type tmpdir := t.TempDir() diff --git a/run.go b/run.go index dd61b19..eba1303 100644 --- a/run.go +++ b/run.go @@ -15,7 +15,9 @@ import ( // if it has an identifiable ext4 filesystem to shrink, and there is enough space to shrink it. // It always will try to run e2fsck before shrinking. By default, it will not fix any found errors, in which case it will // error out if any filesystem errors are found. If fixErrors is true, it will attempt to fix any found errors. -func Run(disk string, shrinkPartition *PartitionIdentifier, growPartitions []PartitionChange, fixErrors, dryRun bool) error { +// If preserveNumbers is true, any partition that is relocated while growing is renumbered back to its original +// partition number once the data has been copied, so its partition number (e.g. /dev/sda2) is unchanged by the resize. +func Run(disk string, shrinkPartition *PartitionIdentifier, growPartitions []PartitionChange, fixErrors, dryRun, preserveNumbers bool) error { // we always work solely with partition UUIDs internally, so convert any other identifiers to UUIDs // see if a disk was specified // no disk specified, try to discover @@ -75,5 +77,5 @@ func Run(disk string, shrinkPartition *PartitionIdentifier, growPartitions []Par return nil } log.Printf("Will perform resizes %+v", resizes) - return resize(d, resizes, fixErrors) + return resize(d, resizes, fixErrors, preserveNumbers) } diff --git a/run_test.go b/run_test.go index 24d88f2..264396b 100644 --- a/run_test.go +++ b/run_test.go @@ -16,6 +16,18 @@ const ( ) func TestRun(t *testing.T) { + for _, preserveNumbers := range []bool{false, true} { + name := "renumber" + if preserveNumbers { + name = "preserveNumbers" + } + t.Run(name, func(t *testing.T) { + runResizeTest(t, preserveNumbers) + }) + } +} + +func runResizeTest(t *testing.T, preserveNumbers bool) { tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "diskfull.img") if err := testCopyFile(diskfullImg, tmpFile); err != nil { @@ -38,10 +50,14 @@ func TestRun(t *testing.T) { } table0 := tableRaw0.(*gpt.Table) var origShrinkSize int64 + origNumber := map[string]int{} for _, p := range table0.Partitions { + if p.Type == gpt.Unused { + continue + } + origNumber[p.Name] = int(p.Index) if p.Name == "shrinker" { origShrinkSize = int64(p.GetSize()) - break } } if origShrinkSize == 0 { @@ -54,7 +70,7 @@ func TestRun(t *testing.T) { NewPartitionChange(IdentifierByLabel, "partb", 2*GB), NewPartitionChange(IdentifierByLabel, "ESP", 1*GB), } - if err := Run(tmpFile, &shrink, growList, false, false); err != nil { + if err := Run(tmpFile, &shrink, growList, false, false, preserveNumbers); err != nil { t.Fatalf("Run failed: %v", err) } @@ -114,6 +130,11 @@ func TestRun(t *testing.T) { default: t.Errorf("unexpected active partition %q", name) } + // with preserveNumbers, every partition (including the relocated grown ones) + // must keep the number it had before the resize + if preserveNumbers && int(p.Index) != origNumber[name] { + t.Errorf("%s partition number = %d, want %d (preserveNumbers)", name, p.Index, origNumber[name]) + } seen[name] = true } for _, n := range []string{"shrinker", "parta", "partb", "ESP"} {