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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ jobs:
- name: Create test tmp dir
run: sudo mkdir /mnt/tmp && sudo chmod 1777 /mnt/tmp
- name: test
run: TMPDIR=/mnt/tmp go test ./...
# 30m timeout: the restart-safety tests in resume_test.go run real
# end-to-end resizes (shrink + copy of a multi-GB fixture), which can
# exceed the default 10m. Use `-short` to skip them in a fast loop.
run: TMPDIR=/mnt/tmp go test -timeout 30m ./...
99 changes: 88 additions & 11 deletions resize.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,92 @@ func resize(d *disk.Disk, resizes []partitionResizeTarget, fixErrors, preserveNu
return err
}

// swap the partitions, specifically the labels, Type GUIDs, and UUIDs, as well as attributes flags
if err := swapPartitions(d, resizes); err != nil {
// finalize: in a single idempotent step, give each relocated target the
// original partition's identity (name, type GUID, partition GUID,
// attributes), set its partition number (the original number when
// preserveNumbers, otherwise the number it was created with), and remove the
// superseded original partition.
if err := updatePartitions(d, resizes, preserveNumbers); err != nil {
return err
}

// remove the old partitions, optionally renumbering the relocated partitions
// back to their original partition numbers
if preserveNumbers {
if err := removeAndRenumberPartitions(d, resizes); err != nil {
return err
return nil
}

// updatePartitions performs the final, idempotent phase of a resize. For each
// relocated partition it gives the target the identity of its original (name,
// type GUID, partition GUID, attributes), assigns the target's partition number
// (the original number when preserveNumbers, otherwise the number it was created
// with), and removes the now-superseded original -- all in a single partition
// table write.
//
// It supersedes the swapPartitions + removePartitions/removeAndRenumberPartitions
// sequence (still defined below but no longer called). Unlike the swap, it is idempotent:
// it identifies partitions by their on-disk start offset -- the one identifier
// that is stable across this phase, since names and numbers change -- sets the
// desired final state directly rather than exchanging values, and treats an
// already-removed original as a no-op. Re-running after an interruption
// therefore converges instead of undoing a completed operation.
func updatePartitions(d *disk.Disk, resizes []partitionResizeTarget, preserveNumbers bool) 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")
}
// Index active partitions by start sector. Start is the only identifier that
// does not change during this phase (names and numbers do), so it is the
// stable key for locating the target and the original on a re-run.
byStart := make(map[uint64]*gpt.Partition)
for _, p := range table.Partitions {
if p.Type == gpt.Unused {
continue
}
} else {
if err := removePartitions(d, resizes); err != nil {
return err
byStart[p.Start] = p
}
sectorSize := int64(table.LogicalSectorSize)
removeStart := make(map[uint64]bool)
for _, r := range resizes {
if r.original.start == r.target.start {
// shrunk in place: not relocated, so no identity move or removal
continue
}
targetStart := uint64(r.target.start / sectorSize)
originalStart := uint64(r.original.start / sectorSize)
target := byStart[targetStart]
if target == nil {
return fmt.Errorf("target partition for %s at start %d not found", r.original.label, r.target.start)
}
// Copy the original's identity onto the target, but only while the
// original is still present. Once a prior (interrupted) run has removed
// it, the target already carries the final identity and this is skipped.
if original := byStart[originalStart]; original != nil {
log.Printf("finalizing partition at start %d to identity of %s (partition %d); removing original", r.target.start, r.original.label, r.original.number)
target.Name = original.Name
target.Type = original.Type
target.GUID = original.GUID
target.Attributes = original.Attributes
removeStart[originalStart] = true
}
if preserveNumbers {
target.Index = r.original.number
}
}

if len(removeStart) > 0 {
kept := make([]*gpt.Partition, 0, len(table.Partitions))
for _, p := range table.Partitions {
if p.Type != gpt.Unused && removeStart[p.Start] {
continue
}
kept = append(kept, p)
}
table.Partitions = kept
}
if err := d.Partition(table); err != nil {
return fmt.Errorf("failed to write updated partition table: %v", err)
}
return nil
}

Expand Down Expand Up @@ -154,6 +223,14 @@ func copyFilesystems(d *disk.Disk, resizes []partitionResizeTarget) error {
return fmt.Errorf("failed to copy raw data for partition %s: %v", r.original.label, err)
}
case fs.Type() == filesystem.TypeExt4:
// On resume, the target may already hold a complete, matching copy
// from a prior run; in that case skip the reformat+recopy. CompareFS
// is a structural/content equality check against the source, not a
// filesystem integrity check.
if existing, eerr := d.GetFilesystem(r.target.number); eerr == nil && sync.CompareFS(fs, existing) == nil {
log.Printf("partition %d -> %d: target filesystem already matches source, skipping copy", r.original.number, r.target.number)
continue
}
newFS, err := d.CreateFilesystem(disk.FilesystemSpec{
Partition: r.target.number,
FSType: filesystem.TypeExt4,
Expand Down
128 changes: 128 additions & 0 deletions resize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -932,3 +932,131 @@ func TestShrinkFilesystems(t *testing.T) {
}
})
}

// TestUpdatePartitions verifies the idempotent finalize step: relocated copies
// take on their originals' identities (and, with preserveNumbers, their
// numbers), the originals are removed, and re-running is a no-op. The input
// models the state right after copyFilesystems: the originals (2, 3) still
// carry the real identities at their old locations, and the relocated copies
// (5, 6) carry the alternate "<label>_resized2" identities at new locations.
func TestUpdatePartitions(t *testing.T) {
const sector = 512
// layout in sectors (Start is an LBA; Size is in bytes for gpt.Partition)
const (
part1Start = 2048
imgaOrig = part1Start + 36*MB/sector // after part1 (36MB)
dataOrig = imgaOrig + 100*MB/sector // after IMGA original (100MB)
part4Start = dataOrig + 50*MB/sector // after DATA original (50MB)
imgaCopy = part4Start + 36*MB/sector // after part4 (36MB)
dataCopy = imgaCopy + 300*MB/sector // after IMGA copy (300MB)
)

for _, preserveNumbers := range []bool{false, true} {
name := "renumber"
if preserveNumbers {
name = "preserveNumbers"
}
t.Run(name, func(t *testing.T) {
workDir := t.TempDir()
f, err := os.CreateTemp(workDir, "disk.img")
if err != nil {
t.Fatalf("create temp disk: %v", err)
}
defer func() { _ = f.Close() }()
if err := os.Truncate(f.Name(), 1*GB); err != nil {
t.Fatalf("truncate disk: %v", err)
}
d, err := diskfs.OpenBackend(file.New(f, false), diskfs.WithOpenMode(diskfs.ReadWrite))
if err != nil {
t.Fatalf("open disk: %v", err)
}
table := &gpt.Table{
Partitions: []*gpt.Partition{
{Index: 1, Start: part1Start, Size: 36 * MB, Type: gpt.LinuxFilesystem, Name: "part1"},
{Index: 2, Start: imgaOrig, Size: 100 * MB, Type: gpt.LinuxFilesystem, Name: "IMGA"},
{Index: 3, Start: dataOrig, Size: 50 * MB, Type: gpt.LinuxFilesystem, Name: "DATA"},
{Index: 4, Start: part4Start, Size: 36 * MB, Type: gpt.LinuxFilesystem, Name: "part4"},
{Index: 5, Start: imgaCopy, Size: 300 * MB, Type: gpt.LinuxFilesystem, Name: getAlternateLabel("IMGA")},
{Index: 6, Start: dataCopy, Size: 200 * MB, Type: gpt.LinuxFilesystem, Name: getAlternateLabel("DATA")},
},
}
if err := d.Partition(table); err != nil {
t.Fatalf("write partition table: %v", err)
}

resizes := []partitionResizeTarget{
{
original: partitionData{number: 2, label: "IMGA", start: imgaOrig * sector},
target: partitionData{number: 5, start: imgaCopy * sector},
},
{
original: partitionData{number: 3, label: "DATA", start: dataOrig * sector},
target: partitionData{number: 6, start: dataCopy * sector},
},
}

// (idempotency across a re-run is covered end-to-end by
// TestRunResumeAfterInterruption/*/afterUpdatePartitions, which uses
// a fresh disk handle as a real resume does.)
if err := updatePartitions(d, resizes, preserveNumbers); err != nil {
t.Fatalf("updatePartitions failed: %v", err)
}

tableRaw, err := d.GetPartitionTable()
if err != nil {
t.Fatalf("get partition table: %v", err)
}
byIndex := make(map[int]*gpt.Partition)
for _, p := range tableRaw.(*gpt.Table).Partitions {
if p.Type == gpt.Unused {
continue
}
byIndex[p.Index] = p
}

// expected final number->(label, start-sector)
want := map[int]struct {
label string
start uint64
}{
1: {"part1", part1Start},
4: {"part4", part4Start},
}
if preserveNumbers {
want[2] = struct {
label string
start uint64
}{"IMGA", imgaCopy}
want[3] = struct {
label string
start uint64
}{"DATA", dataCopy}
} else {
want[5] = struct {
label string
start uint64
}{"IMGA", imgaCopy}
want[6] = struct {
label string
start uint64
}{"DATA", dataCopy}
}

if len(byIndex) != len(want) {
t.Fatalf("expected %d partitions, got %d", len(want), len(byIndex))
}
for number, w := range want {
p, ok := byIndex[number]
if !ok {
t.Fatalf("expected partition number %d (%s) to exist", number, w.label)
}
if p.Name != w.label {
t.Errorf("partition %d: label = %q, want %q", number, p.Name, w.label)
}
if p.Start != w.start {
t.Errorf("partition %d (%s): start = %d, want %d (data must not move)", number, w.label, p.Start, w.start)
}
}
})
}
}
Loading
Loading