Skip to content

Commit 08b0731

Browse files
committed
image: import an AMI from a zstd disk
A release carries the disk as [raw.zst], as the raw is too big to copy around. Stream a .raw.zst disk into the snapshot. Update the docs to show how to import an AMI from the two released files (.raw.zst and .ami.json), as that's how it should happen in production. Signed-off-by: Martin Pitt <martin@amutable.com>
1 parent ba6af5f commit 08b0731

3 files changed

Lines changed: 117 additions & 16 deletions

File tree

‎docs/user/aws.md‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ The import needs these IAM permissions:
5757

5858
## Importing an AMI
5959

60+
Disk partitions often have a lot of empty space, e.g. an ESP sized for future updates. Only the blocks
61+
holding data are uploaded. This only works with unencrypted snapshots, so the import refuses to run while
62+
[EBS encryption by default](https://docs.aws.amazon.com/ebs/latest/userguide/encryption-by-default.html)
63+
is enabled for the region.
64+
65+
If the import fails while uploading, EC2 cancels the incomplete snapshot after an hour. If it fails while
66+
registering, the completed snapshot stays; the import printed its ID, delete it with
67+
`aws ec2 delete-snapshot`.
68+
69+
### Development mode
70+
71+
This is the convenient way for testing: Building and uploading are a single command, but the entire build
72+
has access to your AWS login from above. Don't do this on untrusted projects!
73+
6074
Run the disk's `[ami]` subtarget:
6175

6276
```sh
@@ -72,14 +86,28 @@ This prints the new AMI ID. Options:
7286
- `--jobs`: the number of blocks uploaded in parallel, 16 by default.
7387
- `--no-tpm`: register the AMI without a NitroTPM, for arm64 bare metal instance types.
7488

75-
Disk partitions often have a lot of empty space, e.g. an ESP sized for future updates. Only the blocks
76-
holding data are uploaded. This only works with unencrypted snapshots, so the import refuses to run while
77-
[EBS encryption by default](https://docs.aws.amazon.com/ebs/latest/userguide/encryption-by-default.html)
78-
is enabled for the region.
89+
### Importing from a release
7990

80-
If the import fails while uploading, EC2 cancels the incomplete snapshot after an hour. If it fails while
81-
registering, the completed snapshot stays; the import printed its ID, delete it with
82-
`aws ec2 delete-snapshot`.
91+
For production, building and uploading should happen as two separate steps, with user and credential
92+
isolation. Add the AMI parameters to your release artifacts:
93+
94+
```Starlark
95+
image.artifacts(
96+
name = "release",
97+
targets = [":image[raw.zst]", ":image[ami]", ...],
98+
)
99+
```
100+
101+
Build the `image.artifacts` target in the build step, without AWS credentials.
102+
103+
In the upload step, log into AWS and upload the AMI from these two files, with the same options as above.
104+
Do that from a trusted checkout such as your project's `main`, not an untrusted development branch.
105+
106+
```sh
107+
tine buck run tine//image:ami-import -- /dir/image_1_x86-64.raw.zst /dir/image_1_x86-64.ami.json
108+
```
109+
110+
This decompresses the disk on the fly during upload.
83111

84112
## Running an instance with credentials
85113

‎image/ami_import.py‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import argparse
1111
import base64
12+
import compression.zstd
1213
import concurrent.futures
1314
import errno
1415
import hashlib
@@ -24,13 +25,30 @@
2425
# The only block size the EBS direct APIs take.
2526
BLOCK_SIZE = 512 * 1024
2627
GIB = 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+
5494
def 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

153193
def 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")

‎image/ami_import_test.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
"""
88

99
import base64
10+
import compression.zstd
1011
import hashlib
1112
import tempfile
1213
import unittest
1314
from pathlib import Path
15+
from typing import override
1416

1517
import ami_import
1618
from ami_import import BLOCK_SIZE
@@ -56,6 +58,37 @@ def test_pads_a_short_last_block(self) -> None:
5658
def test_empty_disk_has_no_blocks(self) -> None:
5759
self.assertEqual(list(ami_import.data_blocks(self.disk(3 * BLOCK_SIZE, {}))), [])
5860

61+
def test_size(self) -> None:
62+
self.assertEqual(ami_import.disk_size(self.disk(BLOCK_SIZE + 10, {3: b"x"})), BLOCK_SIZE + 10)
63+
64+
65+
class CompressedDataBlocksTest(DataBlocksTest):
66+
"""The same disks, zstd-compressed as a release's `.raw.zst`, but still named `.raw`.
67+
68+
The name must not matter, only the content.
69+
"""
70+
71+
@override
72+
def disk(self, size: int, writes: dict[int, bytes]) -> Path:
73+
disk = super().disk(size, writes)
74+
# as `zstd` writes it from a file, with the decompressed size in the frame header
75+
disk.write_bytes(compression.zstd.compress(disk.read_bytes()))
76+
return disk
77+
78+
def test_more_than_the_header_says(self) -> None:
79+
disk = self.disk(BLOCK_SIZE, {0: b"x"})
80+
with disk.open("ab") as f:
81+
f.write(compression.zstd.compress(b"y"))
82+
with self.assertRaisesRegex(SystemExit, "decompresses to 524289 bytes, not the 524288"):
83+
list(ami_import.data_blocks(disk))
84+
85+
def test_no_size_in_header(self) -> None:
86+
disk = self.disk(BLOCK_SIZE, {})
87+
with compression.zstd.ZstdFile(disk, "w") as f:
88+
f.write(b"x")
89+
with self.assertRaisesRegex(SystemExit, "does not record its decompressed size"):
90+
ami_import.disk_size(disk)
91+
5992

6093
class ChecksumTest(unittest.TestCase):
6194
def test_is_base64_sha256(self) -> None:

0 commit comments

Comments
 (0)