Skip to content

feat(build): replace the shell pipeline and linuxkit with eiffel-build - #5

Open
patrislav wants to merge 6 commits into
masterfrom
feat/rust-eiffel-build
Open

patrislav wants to merge 6 commits into
masterfrom
feat/rust-eiffel-build

Conversation

@patrislav

@patrislav patrislav commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Today the build is a shell script driving two binaries we do not own: a downloaded linuxkit release, used purely as a YAML-driven cpio packer, and AWS's eif_build example, compiled from a pinned commit against a vendored lock file. Three moving parts, none of them ours, to turn a layout file into an image. This replaces all of it with one program in this repo that we can read, test and change.

First of three stacked pull requests. The enclave's contents do not change here, so the only PCR movement comes from cpio and gzip byte differences against LinuxKit's encoder.

What lands

  • A Cargo workspace at the repository root with a committed lock file, a pinned toolchain and release settings chosen for reproducibility, so the enclave binary added later inherits them.
  • eiffel-build: a CLI, a layout parser, a hand-written newc cpio writer, gzip with a zeroed header timestamp, and image assembly through aws-nitro-enclaves-image-format. It forbids unsafe code.
  • The layout parser accepts only the LinuxKit files: subset our two layouts actually use and rejects everything else, so a layout carrying init: or onboot: fails loudly instead of quietly producing an empty ramdisk. It also rejects absolute paths, .., empty segments, duplicates and ambiguous entries, and creates missing parent directories the way the kernel's unpacker requires.
  • The bootstrap ramdisk is added before the customer one. PCR1 covers the first ramdisk, so that order is a correctness requirement rather than a style choice.
  • A new output alongside the image, holding the measured PCR values as JSON. This is what attestation policies should pin.
  • The shell script and the vendored lock file are deleted. The Dockerfile gains a pinned builder stage and a slim final stage. The kernel stage splits in two so that later work needing only the unpacked source tree does not have to compile a kernel; the compile itself is unchanged.
  • Two workflows. Pull requests run formatting, lints and tests only. The image build, the end-to-end example build and the reproducibility double-build run on master, on tags and on manual dispatch, so no pull request compiles a kernel.

The published image keeps its contract: same entrypoint behaviour, same blob, config, workspace and output paths, so existing customer builds work unchanged.

Verification

  • Formatting, clippy with warnings denied, and the test suite all pass. Nine tests: schema rejection cases, a cpio header golden test, a byte-for-byte determinism test and padding checks.
  • The cpio golden test was generated with GNU cpio in a container, since the macOS cpio is bsdcpio and rejects the reproducible flag. Two deviations from GNU's output are documented in the test: the mtime is zeroed and inodes are numbered from one.
  • Generated archives pass gzip -t, and listing them shows exactly the entries and modes the layouts call for, with zero ownership.
  • Two runs over the same inputs produce identical PCR output. The image itself differs only in an unmeasured build-time field.
  • Error paths leave no partial image behind: the output is written to a temp file and renamed on success.
  • The builder stage and the final stage were each built under emulation and the binary was exercised inside the final image.

An extra dependency

serde_json, beyond the small set this crate otherwise needs. The image-format crate types two of its metadata fields as JSON values, so the struct cannot be constructed without naming the crate. It was already in the tree transitively, and it also serialises the PCR output.

Not verified

The full image was never assembled, since the kernel stage takes eight minutes under emulation on the machine this was built on. No image was booted, on Nitro hardware or otherwise, and no PCR value was compared against a running enclave. The QEMU boot test is written but has never had a chance to run.

Impact

PCR0, PCR1 and PCR2 all move. Consumers must re-pin from the new PCR output.

🤖 Generated with Claude Code

Base automatically changed from feat/build-blobs-from-source to master November 2, 2026 15:55
@patrislav
patrislav requested a review from a team November 2, 2026 15:55
patrislav and others added 5 commits November 2, 2026 17:01
…ipeline

eiffel-build reads the two layout YAMLs, packs each into a gzipped newc cpio
archive and assembles the EIF with aws-nitro-enclaves-image-format. It does in
one process what eiffel.sh did with the linuxkit binary and the eif_build
example CLI, and its defaults reproduce the paths that script used.

The cpio encoder is written by hand: newc is a 110-byte ASCII header and a
crate buys nothing, while the determinism the enclave needs (uid/gid 0, mtime
0, inodes numbered in emission order, a gzip header with no timestamp) is
easier to guarantee than to verify through someone else's writer. A golden
test locks the header layout against GNU cpio's output.

The layout schema is the LinuxKit `files:` subset and nothing more, with
deny_unknown_fields on both structs, so an old layout carrying `init:` or
`onboot:` fails loudly instead of producing a ramdisk missing its contents.

Alongside the .eif and its checksum the tool now writes <name>.pcrs.json, so
consumers can pin PCR0/1/2 from the build rather than reading them back out of
nitro-cli.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eif_build

The final image now carries one host-side binary instead of two downloaded or
cloned ones: the eif_build stage (a git clone of aws-nitro-enclaves-image-format
plus a vendored Cargo.lock to stop its dependencies floating), the linuxkit
release binary and eiffel.sh are all gone, replaced by a cargo build of
eiffel-build from this repo.

The final stage moves from ubuntu:26.04 to debian:bookworm-slim with libssl3,
which is what the image-format crate links against; its KMS signing path is
never entered but ca-certificates comes along for it. The kernel, init, pid1,
chrony and certs stages are untouched, so nothing measured into PCR0 or PCR1
moves because of this commit.

The customer example takes the base image as a build arg so CI can point it at
an image it just built; its default still pins the published tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three jobs on every pull request: the Rust checks, an image build that runs the
customer example end to end and boots the result, and a double build of the
blobs that land in PCR0 and PCR1 to catch a stage that stopped being
reproducible. release.yml is unchanged.

The boot test needs QEMU 9.2 or newer for its nitro-enclave machine type and
vhost-device-vsock for the parent side of the heartbeat; neither is on a stock
runner yet, so the job checks for both and skips with a notice rather than
failing when they are missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The image job compiled the 6.18 kernel on every PR and the reproducible job
compiled it twice more, while the QEMU boot test never ran at all: Debian and
Ubuntu build QEMU without libcbor, so the nitro-enclave machine and virtio-nsm
are compiled out at every version and the machine-type probe skipped the test
silently.

ci.yml keeps only the Rust checks and stays on pull_request. The image and
reproducible jobs move to enclave.yml, which runs on master, on v* tags and on
demand; reproducible narrows further to tags and dispatch, being two full kernel
builds. The boot test now runs inside a Fedora container, whose QEMU does have
the machine type, and the script fails loudly rather than skipping when it does
not.

Splitting the Dockerfile's kernel stage in two lets anything that only needs the
unpacked tree stop at kernel-src. The build steps are unchanged, so bzImage is
byte-for-byte what it was.

Runners are pinned to ubuntu-24.04 while GitHub rolls ubuntu-latest to 26.04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@patrislav
patrislav force-pushed the feat/rust-eiffel-build branch from 23225a8 to 9e70dc3 Compare November 2, 2026 16:15
Comment thread .github/workflows/ci.yml Fixed
Comment thread .github/workflows/enclave.yml Fixed
Comment thread .github/workflows/enclave.yml Fixed
CodeQL flagged all three workflows for running with the default write-all
GITHUB_TOKEN. Give each one a top-level permissions block: ci and enclave
need only contents:read, and release raises packages:write at job level so
the top level can stay empty.

While here, pin what CI pulls and make a hung run cheap:

  - actions/checkout by commit SHA (v7.0.1). v6 writes credentials to a
    separate file rather than the git config and v7 refuses to check out
    fork PRs under pull_request_target/workflow_run, so take the bump
    rather than pinning the old major; nothing here depends on what
    changed in between.
  - persist-credentials: false on every checkout. Nothing pushes with git.
  - the Fedora boot-test container by digest, like the Dockerfile's images.
  - vhost-device-vsock to 0.3.0, the only thing CI installs from crates.io
    and the version the boot test was verified against.
  - release.yml's runner to ubuntu-24.04, matching the others while the
    ubuntu-latest label rolls over.

release.yml also stops interpolating ${{ secrets.GITHUB_TOKEN }} and
${{ github.actor }} into shell source; both go through env instead.

Add timeout-minutes to every job and concurrency to every workflow, except
that tag runs are never cancelled: those are what validate and publish a
release.

Finally, dependabot for github-actions and cargo, monthly. SHA pins rot
silently without something to bump them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@patrislav
patrislav added this pull request to stack #8 November 2, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants