66re-encoded as qcow2 on output (the temporaries are removed afterwards).
77
88The 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
1217Usage:
1318 convert.py <source.img> <output.img>
1419
1520Requires: 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
2834RATIO = DST_SECTOR // SRC_SECTOR # 8
2935HEADROOM = 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
3241def 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+
181369def 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