99
1010import argparse
1111import base64
12+ import compression .zstd
1213import concurrent .futures
1314import errno
1415import hashlib
2425# The only block size the EBS direct APIs take.
2526BLOCK_SIZE = 512 * 1024
2627GIB = 1024 ** 3
28+ # Biggest possible zstd frame header, for reading the decompressed size.
29+ ZSTD_FRAME_HEADER_MAX = 18
2730
2831
29- def data_blocks (disk : Path ) -> Iterator [tuple [int , bytes ]]:
30- """Yield each block of `disk` that holds anything but zeros, with its index.
32+ def zstd_size (disk : Path ) -> int | None :
33+ """The decompressed size of a zstd-compressed `disk`, or None for an uncompressed one."""
34+ with disk .open ("rb" ) as f :
35+ header = f .read (ZSTD_FRAME_HEADER_MAX )
36+ try :
37+ size = compression .zstd .get_frame_info (header ).decompressed_size
38+ except compression .zstd .ZstdError :
39+ return None
40+ if size is None :
41+ sys .exit (f"ami-import: { disk } does not record its decompressed size" )
42+ return size
3143
32- Holes are never read, so a sparse disk costs its allocated size rather than its apparent one.
33- """
44+
45+ def disk_size (disk : Path ) -> int :
46+ """The size of `disk`, decompressed if it is zstd-compressed."""
47+ size = zstd_size (disk )
48+ return disk .stat ().st_size if size is None else size
49+
50+
51+ def _allocated_blocks (disk : Path ) -> Iterator [tuple [int , bytes ]]:
3452 with disk .open ("rb" ) as f :
3553 fd = f .fileno ()
3654 size = os .fstat (fd ).st_size
@@ -45,12 +63,34 @@ def data_blocks(disk: Path) -> Iterator[tuple[int, bytes]]:
4563 raise
4664 end = os .lseek (fd , start , os .SEEK_HOLE )
4765 for index in range (start // BLOCK_SIZE , (end + BLOCK_SIZE - 1 ) // BLOCK_SIZE ):
48- block = os .pread (fd , BLOCK_SIZE , index * BLOCK_SIZE ).ljust (BLOCK_SIZE , b"\0 " )
49- if block .count (0 ) != BLOCK_SIZE :
50- yield index , block
66+ yield index , os .pread (fd , BLOCK_SIZE , index * BLOCK_SIZE ).ljust (BLOCK_SIZE , b"\0 " )
5167 offset = (end + BLOCK_SIZE - 1 ) // BLOCK_SIZE * BLOCK_SIZE
5268
5369
70+ def _decompressed_blocks (disk : Path , size : int ) -> Iterator [tuple [int , bytes ]]:
71+ with compression .zstd .open (disk ) as f :
72+ index = 0
73+ while block := f .read (BLOCK_SIZE ):
74+ yield index , block .ljust (BLOCK_SIZE , b"\0 " )
75+ index += 1
76+ # The snapshot was started with the size the first frame records.
77+ if f .tell () != size :
78+ sys .exit (f"ami-import: { disk } decompresses to { f .tell ()} bytes, not the { size } its header says" )
79+
80+
81+ def data_blocks (disk : Path ) -> Iterator [tuple [int , bytes ]]:
82+ """Yield each block of `disk` that holds anything but zeros, with its index.
83+
84+ A zstd-compressed disk is decompressed on the fly. Holes of an uncompressed one are never read,
85+ so a sparse disk costs its allocated size rather than its apparent one.
86+ """
87+ size = zstd_size (disk )
88+ blocks = _allocated_blocks (disk ) if size is None else _decompressed_blocks (disk , size )
89+ for index , block in blocks :
90+ if block .count (0 ) != BLOCK_SIZE :
91+ yield index , block
92+
93+
5494def checksum (block : bytes ) -> str :
5595 """The per-block checksum `PutSnapshotBlock` requires: base64 of its SHA256."""
5696 return base64 .b64encode (hashlib .sha256 (block ).digest ()).decode ()
@@ -83,7 +123,7 @@ def upload(aws: Aws, disk: Path, name: str, jobs: int) -> str:
83123 if aws ("ec2" , "get-ebs-encryption-by-default" )["EbsEncryptionByDefault" ]:
84124 sys .exit ("ami-import: EBS encryption by default is enabled for this account and region; disable it" )
85125
86- size = (disk . stat (). st_size + GIB - 1 ) // GIB
126+ size = (disk_size ( disk ) + GIB - 1 ) // GIB
87127 snapshot = str (
88128 aws (
89129 "ebs" ,
@@ -152,7 +192,7 @@ def register(aws: Aws, parameters: dict[str, Any], name: str, snapshot: str, tpm
152192
153193def main () -> None :
154194 parser = argparse .ArgumentParser (description = __doc__ )
155- parser .add_argument ("disk" , type = Path , help = "the .raw disk to upload" )
195+ parser .add_argument ("disk" , type = Path , help = "the .raw[.zst] disk to upload" )
156196 parser .add_argument ("parameters" , type = Path , help = "the disk's RegisterImage parameters (.ami.json)" )
157197 parser .add_argument ("--name" , help = "the image name, instead of the parameters'; EC2 requires it unique" )
158198 parser .add_argument ("--region" , help = "the region to import into, instead of the CLI's configured one" )
0 commit comments