Skip to content

Commit 22051fb

Browse files
committed
feat: rebuild ESP as a 4096-byte-sector FAT during 4Kn conversion
A verbatim partition copy carries the EFI System Partition's FAT across unchanged, so its boot sector still declares bytes/sector=512. On a 4Kn target that FAT can't be mounted: the Linux vfat driver requires the FAT's logical sector size to match the device's logical block size. In our deployment this is where the Ironic Python Agent fails -- it cannot mount the ESP to install the bootloader. (UEFI firmware would reject it later for the same reason.) The filesystem can't be fixed by patching the BPB byte alone, because a FAT's whole geometry (FAT table size, cluster addressing, reserved/region layout) is expressed in units of the logical sector size. Detect the ESP by its GPT type GUID and, instead of copying it verbatim, rebuild its filesystem at 4096-byte sectors with its files preserved: - extract the source ESP's files with mcopy - format a same-sized image at 4096-byte sectors with mformat (-S 5, FAT32, label preserved; -T total-sectors is required, otherwise mformat sizes the FS by dividing the file by 512 and overshoots 8x) - verify the result with minfo (sector size + total sectors) - copy the files back and write it into the destination ESP region All other partitions still copy verbatim: ext4 and the raw BIOS-boot area are sector-size-agnostic. mtools is now required, but only when the image contains an ESP (sfdisk + dd still suffice for ESP-less raw images).
1 parent 8681526 commit 22051fb

1 file changed

Lines changed: 200 additions & 16 deletions

File tree

scripts/convert_image_to_4k.py

Lines changed: 200 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
66
re-encoded as qcow2 on output (the temporaries are removed afterwards).
77
88
The conversion preserves the byte offset and size of every partition, so the
9-
filesystems inside copy across unchanged -- only the partition table is rebuilt
10-
for 4096-byte logical sectors. The raw path uses no loop devices and no root.
9+
filesystems inside copy across unchanged. The partition table is rebuilt for
10+
4096-byte logical sectors, and the EFI System Partition's FAT is rebuilt at
11+
4096-byte sectors too (its files preserved) -- a verbatim 512-sector FAT records
12+
bytes/sector=512 in its BPB, which a 4Kn device can't mount (the Linux vfat
13+
driver requires the FAT's sector size to match the device's logical block size;
14+
UEFI firmware likewise won't read it). ext4 and the raw BIOS-boot partition are
15+
sector-size-agnostic and copy verbatim. No loop devices, no root.
1116
1217
Usage:
1318
convert.py <source.img> <output.img>
1419
1520
Requires: python3, sfdisk (util-linux 2.26+), dd (GNU coreutils).
21+
mtools is required when the image contains an ESP (mformat/mcopy/...).
1622
qemu-img is additionally required only for qcow2 input.
1723
"""
1824

@@ -28,6 +34,9 @@
2834
RATIO = DST_SECTOR // SRC_SECTOR # 8
2935
HEADROOM = 64 * 1024 * 1024 # tail room for the backup GPT + alignment
3036

37+
# GPT type GUID of an EFI System Partition (its FAT must be rebuilt for 4Kn).
38+
ESP_TYPE_GUID = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"
39+
3140

3241
def die(msg):
3342
print(f"ERROR: {msg}", file=sys.stderr)
@@ -38,12 +47,13 @@ def info(msg):
3847
print(f">>> {msg}")
3948

4049

41-
def run(cmd, *, input=None, capture=False, what=None):
50+
def run(cmd, *, input=None, capture=False, what=None, env=None):
4251
"""Run a command; die with context on failure. Returns stdout if captured."""
4352
res = subprocess.run(
4453
cmd,
4554
input=input.encode() if input is not None else None,
4655
stdout=subprocess.PIPE if capture else None,
56+
env=env,
4757
)
4858
if res.returncode != 0:
4959
die(f"{what or cmd[0]} failed (exit {res.returncode})")
@@ -76,16 +86,23 @@ def transform_table(dump):
7686
return "\n".join(out) + "\n"
7787

7888

79-
def partition_ranges(dump):
80-
"""Yield (offset_bytes, size_bytes) for each partition in the source dump.
89+
def partitions(dump):
90+
"""Yield (offset_bytes, size_bytes, type_guid) for each partition in the dump.
8191
8292
Byte offsets are identical on source and destination by construction.
8393
"""
8494
for line in dump.splitlines():
8595
m_start = re.search(r"start=\s*(\d+)", line)
8696
m_size = re.search(r"size=\s*(\d+)", line)
87-
if m_start and m_size:
88-
yield int(m_start.group(1)) * SRC_SECTOR, int(m_size.group(1)) * SRC_SECTOR
97+
if not (m_start and m_size):
98+
continue
99+
m_type = re.search(r"type=\s*([0-9A-Fa-f-]+)", line)
100+
type_guid = m_type.group(1).upper() if m_type else ""
101+
yield (
102+
int(m_start.group(1)) * SRC_SECTOR,
103+
int(m_size.group(1)) * SRC_SECTOR,
104+
type_guid,
105+
)
89106

90107

91108
# Known disk-image container formats, keyed by a magic signature at offset 0.
@@ -148,10 +165,19 @@ def convert_raw(src, dst):
148165
print(f" {line}")
149166

150167
# -- Copy partition contents ----------------------------------------------
151-
# Each partition copies from its source byte range to the same destination
168+
# Most partitions copy from their source byte range to the same destination
152169
# byte range; dd's *_bytes flags address by byte while using a large block.
170+
# The ESP is special-cased: its FAT is rebuilt at 4096-byte sectors instead
171+
# of copied verbatim (see rebuild_esp).
153172
info("Copying partition contents...")
154-
for off, sz in partition_ranges(dump):
173+
for off, sz, type_guid in partitions(dump):
174+
if type_guid == ESP_TYPE_GUID:
175+
info(
176+
f" ESP offset={off}B size={sz}B -> rebuilding FAT at "
177+
f"{DST_SECTOR}-byte sectors"
178+
)
179+
rebuild_esp(src, dst, off, sz)
180+
continue
155181
info(f" offset={off}B size={sz}B")
156182
run(
157183
[
@@ -178,6 +204,168 @@ def convert_raw(src, dst):
178204
)
179205

180206

207+
def rebuild_esp(src, dst, off, sz):
208+
"""Rebuild the ESP's FAT at 4096-byte sectors, preserving its files.
209+
210+
A verbatim-copied ESP keeps bytes/sector=512 in its FAT BPB, which a 4Kn
211+
device can't mount: the Linux vfat driver needs the FAT's sector size to
212+
match the device's logical block size (this is where the Ironic deploy agent
213+
fails when installing the bootloader; UEFI firmware likewise won't read it).
214+
We extract the source ESP's files, format a fresh FAT with 4096-byte logical
215+
sectors into a same-sized image, copy the files back, and write it into the
216+
destination ESP region. Pure file ops -- no mount, no root.
217+
"""
218+
for cmd in ("mformat", "mcopy", "minfo", "mlabel"):
219+
if shutil.which(cmd) is None:
220+
die(f"Missing required command: {cmd} (install mtools to rebuild the ESP)")
221+
222+
# MTOOLS_SKIP_CHECK relaxes mtools' geometry sanity checks, which otherwise
223+
# trip on plain partition images that have no CHS geometry.
224+
env = {**os.environ, "MTOOLS_SKIP_CHECK": "1"}
225+
outdir = os.path.dirname(os.path.abspath(dst))
226+
src_esp = _mktemp(outdir, "esp-src-", ".raw")
227+
dst_esp = _mktemp(outdir, "esp-dst-", ".raw")
228+
files = tempfile.mkdtemp(dir=outdir, prefix="esp-files-")
229+
try:
230+
# Extract the source ESP region into a standalone FAT image.
231+
run(
232+
[
233+
"dd",
234+
f"if={src}",
235+
f"of={src_esp}",
236+
"bs=8M",
237+
"iflag=skip_bytes,count_bytes",
238+
f"skip={off}",
239+
f"count={sz}",
240+
],
241+
what="dd (extract ESP)",
242+
)
243+
244+
label = _fat_label(src_esp, env)
245+
fat32 = _fat_is_fat32(src_esp)
246+
info(
247+
f" source ESP: label={label or '(none)'} "
248+
f"fat={'32' if fat32 else '12/16'}"
249+
)
250+
251+
# Format an identically-sized image with 4096-byte logical sectors.
252+
# -S 5 => 128 << 5 = 4096-byte sectors; -c 1 keeps 4096-byte clusters
253+
# (matching the source's). mtools sizes the FS from the image length.
254+
with open(dst_esp, "wb") as f:
255+
f.truncate(sz)
256+
# -T (total sectors) is required: mformat otherwise derives the count by
257+
# dividing the file size by 512, ignoring -S, and overshoots 8x.
258+
mfmt = [
259+
"mformat",
260+
"-i",
261+
dst_esp,
262+
"-S",
263+
"5",
264+
"-c",
265+
"1",
266+
"-H",
267+
"0",
268+
"-T",
269+
str(sz // DST_SECTOR),
270+
]
271+
if fat32:
272+
mfmt.append("-F")
273+
if label:
274+
mfmt += ["-v", label]
275+
mfmt.append("::")
276+
run(mfmt, what="mformat", env=env)
277+
_verify_esp_geometry(dst_esp, sz, env)
278+
279+
# Move the file tree across: extract from source, repopulate the new FS.
280+
entries = _esp_extract(src_esp, files, env)
281+
if entries:
282+
run(
283+
["mcopy", "-s", "-n", "-m", "-i", dst_esp, *entries, "::/"],
284+
what="mcopy (populate ESP)",
285+
env=env,
286+
)
287+
else:
288+
info(" source ESP has no files; new FAT left empty")
289+
290+
# Write the rebuilt filesystem into the destination ESP region.
291+
run(
292+
[
293+
"dd",
294+
f"if={dst_esp}",
295+
f"of={dst}",
296+
"bs=8M",
297+
"conv=notrunc,fsync",
298+
"oflag=seek_bytes",
299+
f"seek={off}",
300+
],
301+
what="dd (write rebuilt ESP)",
302+
)
303+
finally:
304+
for path in (src_esp, dst_esp):
305+
try:
306+
os.remove(path)
307+
except FileNotFoundError:
308+
pass
309+
shutil.rmtree(files, ignore_errors=True)
310+
311+
312+
def _fat_label(img, env):
313+
"""Read a FAT image's volume label via mlabel, or "" if it has none."""
314+
out = run(["mlabel", "-i", img, "-s", "::"], capture=True, what="mlabel", env=env)
315+
for line in (out or "").splitlines():
316+
line = line.strip()
317+
if line.startswith("Volume label is "):
318+
return line[len("Volume label is ") :].split(" (")[0].strip()
319+
return ""
320+
321+
322+
def _fat_is_fat32(img):
323+
"""Return True if the FAT image is FAT32 (per its BPB)."""
324+
with open(img, "rb") as f:
325+
bpb = f.read(512)
326+
root_entries = int.from_bytes(bpb[17:19], "little")
327+
fatsz16 = int.from_bytes(bpb[22:24], "little")
328+
return root_entries == 0 and fatsz16 == 0
329+
330+
331+
def _verify_esp_geometry(img, sz, env):
332+
"""Confirm mformat produced a 4096-byte-sector FS spanning the whole image."""
333+
out = run(["minfo", "-i", img, "::"], capture=True, what="minfo", env=env) or ""
334+
m_ss = re.search(r"sector size:\s*(\d+)", out)
335+
if not m_ss or int(m_ss.group(1)) != DST_SECTOR:
336+
die(f"rebuilt ESP has wrong sector size; minfo said:\n{out}")
337+
m_big = re.search(r"big size:\s*(\d+)", out)
338+
m_small = re.search(r"small size:\s*(\d+)", out)
339+
total = (
340+
int(m_big.group(1))
341+
if m_big and int(m_big.group(1))
342+
else (int(m_small.group(1)) if m_small else 0)
343+
)
344+
expected = sz // DST_SECTOR
345+
if total != expected:
346+
die(
347+
f"rebuilt ESP spans {total} sectors, expected {expected} "
348+
f"(mformat did not size to the partition); minfo:\n{out}"
349+
)
350+
351+
352+
def _esp_extract(img, dest_dir, env):
353+
"""Extract all top-level entries of a FAT image into dest_dir.
354+
355+
Returns the list of extracted local paths. An empty ESP yields []."""
356+
res = subprocess.run(
357+
["mcopy", "-s", "-n", "-m", "-i", img, "::/*", dest_dir + "/"],
358+
stderr=subprocess.PIPE,
359+
env=env,
360+
)
361+
entries = [os.path.join(dest_dir, e) for e in sorted(os.listdir(dest_dir))]
362+
if res.returncode != 0 and not entries:
363+
return [] # empty ESP: the glob matched nothing
364+
if res.returncode != 0:
365+
die(f"mcopy (extract ESP) failed: {res.stderr.decode().strip()}")
366+
return entries
367+
368+
181369
def convert_qcow2(src, dst):
182370
"""Convert a qcow2 image: decompress to raw, rescale, re-encode as qcow2."""
183371
if shutil.which("qemu-img") is None:
@@ -229,16 +417,12 @@ def print_notes(dst, out_fmt):
229417
f"Write to the 4Kn device with: dd if='{dst}' of=/dev/<disk> bs=8M conv=fsync"
230418
)
231419
info("")
232-
info("Partition contents are copied verbatim; filesystems and boot code are not")
233-
info("modified beyond rewriting the partition table. Two things to know:")
234-
info(" - UEFI boot: no bootloader changes needed. The ESP is preserved and GRUB")
235-
info(" finds its config/modules by partition number + path, both unchanged.")
420+
info("The ESP was rebuilt as a 4096-byte-sector FAT (its files preserved); other")
236421
info(
237-
" But the ESP's FAT was made with 512-byte logical sectors (BPB bytes/sector"
422+
"partitions were copied verbatim. UEFI boot needs no bootloader changes -- GRUB"
238423
)
239-
info(" = 512); some 4Kn firmware refuses that. If the ESP won't mount after")
240424
info(
241-
" deploy, rebuild it for 4096-byte sectors (mkfs.vfat -F32 -S 4096) + restore."
425+
"finds its config/modules by partition number + path, both unchanged. One note:"
242426
)
243427
info(" - Legacy BIOS boot: reinstall the bootloader. The MBR boot code is not")
244428
info(" carried over and GRUB's embedded core.img sector is a 512-byte LBA:")

0 commit comments

Comments
 (0)