Skip to content

Commit f18f05f

Browse files
authored
fix(orchestrator): measure ext4 free space from block groups (#3282)
The ensure-free-disk phase previously trusted debugfs's top-level "Free blocks" value, which comes from the ext4 superblock. After an unclean shutdown, Linux can leave this aggregate stale, and e2fsck -E journal_only returns before rebuilding it from the block-group descriptors. This could cause builds to make an incorrect resize decision and leave less free space than requested. Flush the NBD device after replaying the journal, then calculate free space by summing debugfs's per-group counters and subtracting reserved blocks. Require one counter for every group so incomplete or unexpected output fails instead of silently producing an incorrect result. Pin LC_ALL=C because the parser relies on debugfs's English field names. Add Linux unit coverage for stale global counters, multi-group sums, singular block output, incomplete group output, and global-only output.
1 parent 69c06b6 commit f18f05f

3 files changed

Lines changed: 60 additions & 20 deletions

File tree

packages/orchestrator/pkg/template/build/core/filesystem/ext4.go

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import (
2626

2727
var tracer = otel.Tracer("github.com/e2b-dev/infra/packages/orchestrator/pkg/template/build/core/filesystem")
2828

29+
var (
30+
groupHeaderPattern = regexp.MustCompile(`(?m)^[ \t]*Group[ \t]+\d+:`)
31+
groupFreeBlocksPattern = regexp.MustCompile(`(?m)^[ \t]+(\d+)[ \t]+free blocks?,`)
32+
reservedBlocksPattern = regexp.MustCompile(`Reserved block count:\s+(\d+)`)
33+
)
34+
2935
const (
3036
// creates an inode for every bytes-per-inode byte of space on the disk
3137
inodesRatio = int64(4096)
@@ -196,6 +202,8 @@ func GetFreeSpace(ctx context.Context, rootfsPath string, blockSize int64) (int6
196202
defer statSpan.End()
197203

198204
cmd := exec.CommandContext(ctx, "debugfs", "-R", "stats", rootfsPath)
205+
// The parser below relies on debugfs's English field names.
206+
cmd.Env = append(os.Environ(), "LC_ALL=C")
199207
var out bytes.Buffer
200208
cmd.Stdout = &out
201209
err := cmd.Run()
@@ -206,7 +214,6 @@ func GetFreeSpace(ctx context.Context, rootfsPath string, blockSize int64) (int6
206214
return 0, fmt.Errorf("error statting ext4: %w", err)
207215
}
208216

209-
// Extract block size and free blocks
210217
freeBlocks, err := parseFreeBlocks(output)
211218
if err != nil {
212219
return 0, fmt.Errorf("could not parse free blocks: %w", err)
@@ -376,25 +383,38 @@ func LogMetadata(ctx context.Context, rootfsPath string, extraFields ...zap.Fiel
376383
logger.L().With(extraFields...).Debug(ctx, "tune2fs -l output", zap.String("path", rootfsPath), zap.String("output", string(output)), zap.Error(err))
377384
}
378385

379-
// parseFreeBlocks extracts the "Free blocks:" value from debugfs output
386+
// parseFreeBlocks returns the sum of the free-block counters stored in the
387+
// block-group descriptors. e2fsck documents that the global count can be stale
388+
// after an unclean unmount and normally rebuilds it from the group counts, but
389+
// its journal_only path skips that repair:
390+
// https://github.com/tytso/e2fsprogs/blob/v1.47.0/e2fsck/unix.c#L370-L441
391+
// debugfs prints each group from ext2fs_bg_free_blocks_count(), so those are the
392+
// counters we must sum after replaying only the journal:
393+
// https://github.com/tytso/e2fsprogs/blob/v1.47.0/debugfs/debugfs.c#L486-L501
380394
func parseFreeBlocks(debugfsOutput string) (int64, error) {
381-
re := regexp.MustCompile(`Free blocks:\s+(\d+)`)
382-
matches := re.FindStringSubmatch(debugfsOutput)
383-
if len(matches) < 2 {
384-
return 0, errors.New("could not find free blocks in debugfs output")
395+
groups := groupHeaderPattern.FindAllStringIndex(debugfsOutput, -1)
396+
matches := groupFreeBlocksPattern.FindAllStringSubmatch(debugfsOutput, -1)
397+
// Require one counter per group so truncated or unexpected debugfs output
398+
// cannot silently undercount free space and trigger an incorrect resize.
399+
if len(groups) == 0 || len(matches) != len(groups) {
400+
return 0, fmt.Errorf("could not parse free blocks for every block group: found %d groups and %d counters", len(groups), len(matches))
385401
}
386-
freeBlocks, err := strconv.ParseInt(matches[1], 10, 64)
387-
if err != nil {
388-
return 0, fmt.Errorf("could not parse free blocks: %w", err)
402+
403+
var freeBlocks int64
404+
for _, match := range matches {
405+
groupFreeBlocks, err := strconv.ParseInt(match[1], 10, 64)
406+
if err != nil {
407+
return 0, fmt.Errorf("could not parse block-group free blocks: %w", err)
408+
}
409+
freeBlocks += groupFreeBlocks
389410
}
390411

391412
return freeBlocks, nil
392413
}
393414

394-
// parseReservedBlocks extracts the "Reserved block count:" value from debugfs output
415+
// parseReservedBlocks extracts the "Reserved block count:" value from debugfs output.
395416
func parseReservedBlocks(debugfsOutput string) (int64, error) {
396-
re := regexp.MustCompile(`Reserved block count:\s+(\d+)`)
397-
matches := re.FindStringSubmatch(debugfsOutput)
417+
matches := reservedBlocksPattern.FindStringSubmatch(debugfsOutput)
398418
if len(matches) < 2 {
399419
return 0, errors.New("could not find reserved blocks in debugfs output")
400420
}

packages/orchestrator/pkg/template/build/core/filesystem/ext4_test.go

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,33 @@ func TestParseFreeBlocks(t *testing.T) {
1919
wantErr bool
2020
}{
2121
{
22-
name: "standard debugfs output",
23-
input: "Block count: 131072\nFree blocks: 120000\nFirst block: 0\n",
24-
expected: 120000,
22+
name: "sums group counters instead of stale superblock",
23+
input: `Free blocks: 50000
24+
Group 0: block bitmap at 1, inode bitmap at 2, inode table at 3
25+
28629 free blocks, 100 free inodes, 1 used directory
26+
Group 1: block bitmap at 4, inode bitmap at 5, inode table at 6
27+
28639 free blocks, 100 free inodes, 1 used directory
28+
`,
29+
expected: 57268,
30+
},
31+
{
32+
name: "supports singular block counter",
33+
input: ` Group 0: block bitmap at 1, inode bitmap at 2, inode table at 3
34+
1 free block, 1 free inode, 1 used directory
35+
`,
36+
expected: 1,
2537
},
2638
{
27-
name: "large block count",
28-
input: "Free blocks: 999999999\n",
29-
expected: 999999999,
39+
name: "rejects incomplete group output",
40+
input: ` Group 0: block bitmap at 1, inode bitmap at 2, inode table at 3
41+
10 free blocks, 100 free inodes, 1 used directory
42+
Group 1: block bitmap at 4, inode bitmap at 5, inode table at 6
43+
`,
44+
wantErr: true,
3045
},
3146
{
32-
name: "missing free blocks",
33-
input: "Block count: 131072\n",
47+
name: "rejects global counter without block groups",
48+
input: "Free blocks: 50000\n",
3449
wantErr: true,
3550
},
3651
}

packages/orchestrator/pkg/template/build/phases/ensurefreedisk/grow.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ func (b *EnsureFreeDiskBuilder) measureFree(
126126
if _, err := filesystem.ReplayJournal(ctx, device.path); err != nil {
127127
return 0, fmt.Errorf("replay source journal: %w", err)
128128
}
129+
// Make the recovered block-group metadata visible from the backend before
130+
// debugfs reopens the device and reads its free-space counters.
131+
if err := device.mnt.Flush(ctx); err != nil {
132+
return 0, fmt.Errorf("flush recovered source journal: %w", err)
133+
}
129134
free, err = filesystem.GetFreeSpace(ctx, device.path, blockSize)
130135
if err != nil {
131136
return 0, fmt.Errorf("measure source free space: %w", err)

0 commit comments

Comments
 (0)