Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ User guides:

- [Building images](docs/user/images.md): host requirements, declaring images and layers, output formats,
the VM runner
- [Publishing images to AWS EC2](docs/user/aws.md): importing a disk as an AMI, and booting a test
instance
- [Building Rust projects](docs/user/cargo.md): building a checked-out Rust project offline, with its
crate graph in the executables and image SBOM
- [Building Go projects](docs/user/go.md): the same for a checked-out Go project, whose module list go
Expand Down
115 changes: 115 additions & 0 deletions docs/user/aws.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<!--
SPDX-FileCopyrightText: Amutable GmbH <https://amutable.com/>
SPDX-License-Identifier: MPL-2.0
-->

# Publishing images to AWS EC2

An [Amazon Machine Image](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html) (AMI) is not a
file format but an object in one EC2 region of one account: it refers to an EBS snapshot holding the disk,
and records how instances boot it (architecture, boot mode, TPM, network driver). tine builds everything
that is a function of the image, and uploads it as a separate `buck run` step that uses your AWS login.

## The AMI bundle

Every [image.bootable_disk](images.md#imagebootable_disk) has an `[ami]` subtarget: the raw disk and the
parameters to register it with. `buck build` it to get the bundle, `buck run` it to
[import](#importing-an-ami) it.

Nothing about the disk is specific to EC2: every initrd supports
[systemd IMDS](https://www.freedesktop.org/software/systemd/man/devel/systemd-imds.html) for passing
[credentials](https://systemd.io/CREDENTIALS/) and carries the `ena` EC2 network driver. The disk is only
as large as its partitions. The image's `virtual_size` sets the default size of an instance's root volume,
rounded up to GiB, and a launch can override that. On first boot, `systemd-repart` then creates or grows
partitions according to their definitions.

The AMI boots with UEFI only, and has a NitroTPM, which a disk needs if it seals anything to a TPM, such as
a root partition with `Encrypt=tpm2`. That limits it to instance types with NitroTPM support, which leaves
out the bare metal ones. Import with `--no-tpm` for arm64 bare metal; x86 bare metal only boots legacy
BIOS, which a tine disk does not support.

The AMI is named after the disk's published name: `<IMAGE_ID>_<IMAGE_VERSION>_<arch>`. Characters which
EC2 does not accept in names (such as the `^` in versions) get replaced by `-`. The unchanged name is the
AMI's description.

With a `secure_boot_key`, the bundle carries a UEFI variable store that enrolls the key's certificate as PK,
KEK and db, also available as `[ami][uefi-data]`. An instance then enforces Secure Boot from its first boot
on and trusts only this key; `examples/image-secureboot` has such a disk. Without a key, an instance boots
in setup mode with Secure Boot disabled.

## Logging in

tine never handles AWS credentials. It uses the Fedora packaged [AWS CLI](https://aws.amazon.com/de/cli/)
in a box, which runs with your home directory and environment:

```sh
tine buck run tine//tools:aws -- login
tine buck run tine//tools:aws -- configure set region eu-central-1
```

Any other `aws` command works the same way, so you do not need the AWS CLI installed on the host.

The import needs these IAM permissions:

- `ebs:StartSnapshot`, `ebs:PutSnapshotBlock`, `ebs:CompleteSnapshot`
- `ec2:GetEbsEncryptionByDefault`, `ec2:DescribeImages`, `ec2:DescribeSnapshots`, `ec2:RegisterImage`,
`ec2:CreateTags`

## Importing an AMI

Run the disk's `[ami]` subtarget:

```sh
tine buck run '//examples/image:boot-demo.fedora[ami]'
```

This prints the new AMI ID. Options:

- `--name`: register under another name. EC2 requires names to be unique per account and region, and the
import fails before uploading anything if the name is taken, so pass this to import the same image
version again.
- `--region`: import into another region than the configured one.
- `--jobs`: the number of blocks uploaded in parallel, 16 by default.
- `--no-tpm`: register the AMI without a NitroTPM, for arm64 bare metal instance types.

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

If the import fails while uploading, EC2 cancels the incomplete snapshot after an hour. If it fails while
registering, the completed snapshot stays; the import printed its ID, delete it with
`aws ec2 delete-snapshot`.

## Running an instance with credentials

System credentials go into the instance's user data, as a JSON object with a `systemd.credentials` list.

**For testing only**, this one answers the first boot's questions and logs root in on the consoles
without a password, as `image.vm`'s `autologin` does for a local VM:

```json
{"systemd.credentials": [
{"name": "firstboot.locale", "text": "C.UTF-8"},
{"name": "firstboot.timezone", "text": "UTC"},
{"name": "passwd.hashed-password.root", "text": "!*"},
{"name": "agetty.autologin", "text": "root"},
{"name": "login.noauth", "text": "yes"}
]}
```

For a root password, put a hash from `mkpasswd` into `passwd.hashed-password.root` and leave out the last
two. An EC2 key pair (`--key-name`) gets specified in the `ssh.authorized_keys.root` credential (for
production instances).

Launch an instance in the default VPC with that user data:

```sh
tine buck run tine//tools:aws -- ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type t3.small \
--user-data file://user-data.json
```

Add `--block-device-mappings 'DeviceName=/dev/xvda,Ebs={VolumeSize=20}'` for a volume size different than
the AMI's default.
22 changes: 13 additions & 9 deletions docs/user/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ when needed:
split-only invocation, `strip_pkgdb` leaves the package database out of the partitions, and
`mkfs_options` tunes the filesystems it creates;
- `image.disk_convert` re-encodes a raw disk with an explicitly selected box and provides
`image.DiskConversionInfo`; `virtual_size` sizes the disk a qcow2 describes;
`image.DiskConversionInfo`; `virtual_size` sizes the disk a qcow2 or VMDK describes;
- `image.bootable` selects a kernel and matching initrd from a logical image, exposed as `[uki]`, `[kernel]`,
and `[initrd]` subtargets;
- `image.vm` runs the raw image ephemerally with its explicitly selected box's `systemd-vmspawn`, QEMU,
Expand Down Expand Up @@ -530,10 +530,10 @@ Optional attributes:
`image.ddi()`.
- `secure_boot_key` (target providing `image.SigningKeyInfo`): Signs the UKIs and systemd-boot; see "Secure
Boot signing" below.
- `virtual_size` (size string such as `"20G"`): The size of a virtual disk like `[qcow2]`. A system booted
from it can claim the extra space during first boot, e.g. for a root partition. The raw image stays at
the size of its partitions. As with `image.vm`'s `grow`, the extra space sits past the GPT backup header
until something rewrites the table. A size the partitions do not fit in fails the build.
- `virtual_size` (size string such as `"20G"`): The size of a virtual disk (`[qcow2]` or `[vmdk]`). A
system booted from it can claim the extra space during first boot, e.g. for a root partition. The raw
image stays at the size of its partitions. As with `image.vm`'s `grow`, the extra space sits past the GPT
backup header until something rewrites the table. A size the partitions do not fit in fails the build.
- `mkfs_options` (dict of filesystem to option list, default `{}`): Options `mkfs` is given when
creating a partition of that filesystem, passed on to `image.ddi()`, which hands each list to
`systemd-repart` as `SYSTEMD_REPART_MKFS_OPTIONS_<FSTYPE>`. Naming a filesystem the disk never formats
Expand Down Expand Up @@ -619,6 +619,8 @@ views and supply-chain artifacts are lazy subtargets:
├── [directory]
├── [qcow2]
├── [raw.zst]
├── [vmdk]
├── [ami]
├── [manifest]
├── [pkgdb]
├── [sbom]
Expand All @@ -642,9 +644,11 @@ views and supply-chain artifacts are lazy subtargets:

The disk and its re-encodings are files named `<image_id>_<version>_<arch>.<ext>`, so they keep the image
identity when copied out of the build, exactly matching the UKI's `<image_id>_<version>_<arch>.efi`.
The `[qcow2]` and `[raw.zst]` subtargets re-encode the raw disk into a compact qcow2 or a compressed raw on
demand, each publishing one `image.DiskConversionInfo`; the encodings are reachable only through those
subtargets, because one result cannot carry the same provider type twice. The target has no aggregate
The `[qcow2]`, `[raw.zst]` and `[vmdk]` subtargets re-encode the raw disk into a compact qcow2, a compressed
raw, or a compressed (streamOptimized) VMDK for VMware and cloud importers on demand, each publishing one
`image.DiskConversionInfo`; the encodings are reachable only through those
subtargets, because one result cannot carry the same provider type twice. `[ami]` is the raw disk with the
parameters an Amazon Machine Image is registered with, see [aws.md](aws.md). The target has no aggregate
bootable-image provider: it returns `image.ImageInfo`, `image.DdiInfo`, `image.InitrdInfo`,
`image.ImageDirectoryInfo`,
`image.UkiInfo`, and `image.PublishedInfo` independently.
Expand All @@ -656,7 +660,7 @@ names instead of composing names of its own. A partition is the exception and tr
`PartitionInfo`: its name contains the
type and UUID repart assigned, so it exists only after the build, and a consumer reads it back out of the
metadata written beside the partition. The ESP is not published: what it carries is transferred by other
means, and no update writes the partition back. `[qcow2]` and `[raw.zst]` publish themselves rather than
means, and no update writes the partition back. The format subtargets publish themselves rather than
travelling in the disk's own set, so gathering a release does not build every encoding of it. So do the
metadata views: `[sbom]` publishes both formats and `[pkgdb]` one file named after the format its package
system keeps the database in, each named after the image they describe, and for a disk `[initrd][sbom]`
Expand Down
18 changes: 18 additions & 0 deletions examples/image-secureboot/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,24 @@ image.bootable_disk(
sign_expected_pcr_key = _PCR_SIGNING,
)

# The AMI's UEFI variable store enrolls the signing certificate as PK, KEK, and db, so EC2 instances start
# out trusting it. See docs/user/aws.md.
box.sh_test(
name = "uefi-data-test",
args = ["$(location :image[ami][uefi-data])", "$(location {}[cert])".format(_SIGNING)],
box = "@tine//tools:aws.box",
script = """
virt-fw-vars --input "$1" --output-json /dev/stdout | python3 -c '
import json, ssl, sys
cert = ssl.PEM_cert_to_DER_cert(open(sys.argv[1]).read())
variables = {v["name"]: bytes.fromhex(v["data"]) for v in json.load(sys.stdin)["variables"]}
for name in ("PK", "KEK", "db"):
if cert not in variables.get(name, b""):
sys.exit(f"ERROR: {name} does not hold the signing certificate")
' "$2"
""",
)

# System-extension DDI extending :image, signed so the strict policy in sysext.conf accepts the
# merge. The image's own key, whose certificate it carries in /usr/lib/verity.d.
image.sysext(
Expand Down
33 changes: 29 additions & 4 deletions examples/image/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -267,16 +267,41 @@ tests.vm_smoke(
],
)

# The qcow2 describes a disk of the image's virtual_size, while the raw stays as small as its partitions.
# The qcow2 and vmdk describe a disk of the image's virtual_size with the raw's content, while the raw stays
# as small as its partitions.
box.sh_test(
name = "virtual-size-test",
labels = ["image"],
box = "@tine//catalog:fedora.rawhide.box",
script = """
test "$(qemu-img info --output=json "$1" | python3 -c 'import json, sys; print(json.load(sys.stdin)["virtual-size"])')" = 2147483648
test "$(stat -c %s "$2")" -lt $((900 << 20))
raw=$1
shift
test "$(stat -c %s "$raw")" -lt $((900 << 20))
for disk in "$@"; do
test "$(qemu-img info --output=json "$disk" | python3 -c 'import json, sys; print(json.load(sys.stdin)["virtual-size"])')" = 2147483648
qemu-img compare -q -f raw "$raw" "$disk"
done
""",
args = ["$(location :boot-demo.fedora[qcow2])", "$(location :boot-demo.fedora)"],
args = ["$(location :boot-demo.fedora)", "$(location :boot-demo.fedora[qcow2])", "$(location :boot-demo.fedora[vmdk])"],
)

# The AMI bundle carries the disk, and registers it for the image's architecture with a root volume of its
# virtual_size.
box.sh_test(
name = "ami-test",
labels = ["image"],
box = "@tine//tools:dev.box",
script = """
cmp "$1/disk.raw" "$2"
python3 -c '
import json, sys
register = json.load(open(sys.argv[1]))
assert register["Architecture"] == sys.argv[2], register
assert register["BlockDeviceMappings"][0]["Ebs"]["VolumeSize"] == 2, register
' "$1/register.json" "$3"
""",
args = ["$(location :boot-demo.fedora[ami])", "$(location :boot-demo.fedora)"]
+ architecture.select({name: [spellings.ec2] for name, spellings in ARCHITECTURES.items()}),
)

# System-extension DDI extending :boot-demo: only the delta above the base lands in the
Expand Down
12 changes: 11 additions & 1 deletion image/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

load("@prelude//:rules.bzl", "constraint_setting", "constraint_value")
load("//box:test.bzl", "box_python_test")
load("//python:defs.bzl", "tine_python_binary", "tine_python_library")
load("//python:defs.bzl", "box_python_binary", "tine_python_binary", "tine_python_library")
load(":image.bzl", "image_tools")

# The one bundle every image rule resolves its drivers through.
Expand Down Expand Up @@ -108,6 +108,8 @@ box_python_test(
name = "test",
box = "//tools:dev.box",
srcs = [
"ami_import.py",
"ami_import_test.py",
"ddi.py",
"ddi_test.py",
"finalize_test.py",
Expand Down Expand Up @@ -211,6 +213,14 @@ tine_python_binary(
visibility = ["PUBLIC"],
)

# Run by a bootable disk's `[ami]` subtarget, relaxed, with the host's AWS login.
box_python_binary(
name = "ami-import",
box = "//tools:aws.box",
main = "ami_import.py",
visibility = ["PUBLIC"],
)

# Nothing satisfies this value, so a target requiring it cannot be built. A key whose section has no
# token requires it, which keeps it out of `//...` and makes an image that signs with it fail naming
# this target. `prelude//:none` would do the same without naming a reason.
Expand Down
Loading
Loading