From 408bf766e98256dbb3ddab85570f81ae30f06d2d Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Thu, 25 Jun 2026 15:23:48 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20Lambda=20MicroVM?= =?UTF-8?q?=20source=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Dockerfile-based source package for AWS Lambda MicroVM image creation, built by Nix and kept separate from stereOS VM mixtape artifacts (no stereos.img / qcow2 / kernel). `flake/images.nix` exposes `packages..-lambda-microvm-source` for each mixtape. The image entrypoint is a small Rust HTTP hook server (`lambda-microvm/lifecycle`), installed as `/bin/lambda-microvm-lifecycle`. It serves the AWS image-build hooks (`ready`, `validate`) and runtime hooks (`run`, `suspend`, `resume`, `terminate`), plus direct debug endpoints, parses the AWS `runHookPayload` envelope down to its `session` object, and optionally launches `paperd` as uid/gid 1000. The server uses only a blocking HTTP listener (tiny_http) — no async runtime — and drops privilege via the kernel at exec, matching the platform's Rust direction and keeping the audit surface small. Behaviour is locked down by unit tests over dispatch parsing, hook routing, command execution, and the paperd start decision. The rootfs is assembled from a Nix closure. The agent home and its XDG tree are created and owned 1000:1000 at build time, so the image is ready to run with no runtime mkdir/chown. OpenSSH paths are included for the `microvmssh` shell-ingress pattern. Bundles ship the pinned public Paper release binary and set `STEREOS_START_PAPERD=1`. --- Makefile | 5 + README.md | 6 + flake/devshell.nix | 8 + flake/images.nix | 21 +- lambda-microvm/README.md | 96 ++++++ lambda-microvm/lifecycle/.gitignore | 1 + lambda-microvm/lifecycle/Cargo.lock | 325 ++++++++++++++++++ lambda-microvm/lifecycle/Cargo.toml | 29 ++ lambda-microvm/lifecycle/src/command.rs | 145 ++++++++ lambda-microvm/lifecycle/src/dispatch.rs | 126 +++++++ lambda-microvm/lifecycle/src/main.rs | 183 ++++++++++ lambda-microvm/lifecycle/src/paperd.rs | 210 ++++++++++++ lambda-microvm/lifecycle/src/server.rs | 413 +++++++++++++++++++++++ lambda-microvm/lifecycle/src/state.rs | 130 +++++++ lib/lambda-microvm.nix | 158 +++++++++ lib/paper-bin.nix | 44 +++ 16 files changed, 1899 insertions(+), 1 deletion(-) create mode 100644 lambda-microvm/README.md create mode 100644 lambda-microvm/lifecycle/.gitignore create mode 100644 lambda-microvm/lifecycle/Cargo.lock create mode 100644 lambda-microvm/lifecycle/Cargo.toml create mode 100644 lambda-microvm/lifecycle/src/command.rs create mode 100644 lambda-microvm/lifecycle/src/dispatch.rs create mode 100644 lambda-microvm/lifecycle/src/main.rs create mode 100644 lambda-microvm/lifecycle/src/paperd.rs create mode 100644 lambda-microvm/lifecycle/src/server.rs create mode 100644 lambda-microvm/lifecycle/src/state.rs create mode 100644 lib/lambda-microvm.nix create mode 100644 lib/paper-bin.nix diff --git a/Makefile b/Makefile index 423b8d0..a0f411e 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,11 @@ build-kernel: ## Build kernel artifacts for kernel boot $(call require-arch) nix build .#packages.$(ARCH).$(MIXTAPE)-kernel-artifacts --impure +.PHONY: build-lambda-microvm-source +build-lambda-microvm-source: ## Build Dockerfile source zip for AWS Lambda MicroVM image creation + $(call require-arch) + nix build .#packages.$(ARCH).$(MIXTAPE)-lambda-microvm-source --impure + # -- VM development operations ------------------------------------------------ .PHONY: run diff --git a/README.md b/README.md index aae2163..6b73889 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,12 @@ handling agent lifecycle and acting as a control plane for agent operators: | Raw EFI | `system.build.raw` | `stereos.img` | Canonical artifact. Apple Virt Framework bootable | | QCOW2 | `system.build.qcow2` | `stereos.qcow2` | Derived from raw via `qemu-img convert`. QEMU/KVM | | Kernel artifacts | `system.build.kernelArtifacts` | `bzImage`, `initrd`, `cmdline`, `init` | Direct-kernel boot (bypasses UEFI/GRUB) | +| Lambda MicroVM source | `packages..-lambda-microvm-source` | Dockerfile source zip | AWS Lambda MicroVM image creation | + +The Lambda MicroVM source bundle is not a full stereOS VM image. It packages +stereOS userspace into a Dockerfile-based rootfs bundle because AWS Lambda +MicroVM images are created from Dockerfile application sources, not custom +kernel or disk artifacts. ### Distribution (mkDist) diff --git a/flake/devshell.nix b/flake/devshell.nix index 5ee1426..625b2d6 100644 --- a/flake/devshell.nix +++ b/flake/devshell.nix @@ -14,6 +14,14 @@ pkgs.go_1_25 pkgs.gopls pkgs.gotools + # Rust toolchain for the Lambda MicroVM lifecycle crate + # (lambda-microvm/lifecycle). Uses the pinned nixpkgs toolchain, the + # same model as go_1_25 above and buildRustPackage in the image build. + pkgs.cargo + pkgs.rustc + pkgs.clippy + pkgs.rustfmt + pkgs.rust-analyzer pkgs.hurl pkgs.zstd inputs.dagger.packages.${system}.dagger diff --git a/flake/images.nix b/flake/images.nix index 0f68446..278f411 100644 --- a/flake/images.nix +++ b/flake/images.nix @@ -17,6 +17,7 @@ let stereos-lib = import ../lib/dist.nix { inherit inputs; }; + stereos-lambda = import ../lib/lambda-microvm.nix { inherit inputs; }; stereos-main = import ../lib { inherit inputs self; }; # Target architectures to build images for @@ -36,6 +37,7 @@ let buildSystemImages = system: let pkgs = inputs.nixpkgs.legacyPackages.${system}; + paper-bin = import ../lib/paper-bin.nix { inherit pkgs; }; # Build a nixosConfiguration for each mixtape spec, targeting this system. configs = builtins.listToAttrs ( @@ -88,8 +90,25 @@ let }; }) mixtapeNames ); + # Lambda MicroVM source bundles — Dockerfile + Nix-built rootfs tar, + # deliberately separate from VM mixtape artifacts. + lambdaMicrovmPkgs = builtins.listToAttrs ( + builtins.map (name: { + name = "${name}-lambda-microvm-source"; + value = stereos-lambda.mkLambdaMicrovmSource { + inherit pkgs; + name = name; + version = stereos-main.stereosVersion; + agentPackages = + configs.${name}.config.stereos.agent.basePackages + ++ configs.${name}.config.stereos.agent.extraPackages + ++ [ paper-bin ]; + startPaperd = true; + }; + }) mixtapeNames + ); in - rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs; + rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs // lambdaMicrovmPkgs; # Build packages for all target systems allPackages = builtins.listToAttrs ( diff --git a/lambda-microvm/README.md b/lambda-microvm/README.md new file mode 100644 index 0000000..1978c1b --- /dev/null +++ b/lambda-microvm/README.md @@ -0,0 +1,96 @@ +# stereOS Lambda MicroVM Source Bundle + +This bundle is a Dockerfile-based source package for AWS Lambda MicroVM image +creation. It is intentionally separate from stereOS VM mixtapes: it does not +contain `stereos.img`, `stereos.qcow2`, a custom kernel, or direct-kernel boot +artifacts. + +## Source Files + +The checked-in build logic lives in: + +- `lib/lambda-microvm.nix`: assembles the rootfs, generates the Dockerfile, + and zips the source bundle. +- `lambda-microvm/lifecycle/`: the Rust HTTP lifecycle process, built from + source by Nix (`buildRustPackage`) and copied into the rootfs as + `/bin/lambda-microvm-lifecycle`. +- `flake/images.nix`: exposes the generated + `packages..-lambda-microvm-source` package. + +`src/Dockerfile` and `src/stereos-rootfs.tar` are generated files inside the +Nix build output; they are not checked into the repository. + +## Build Locally + +```bash +nix build .#packages.x86_64-linux.coder-lambda-microvm-source --impure +``` + +The Nix result contains: + +- `src/Dockerfile` +- `src/stereos-rootfs.tar` +- `-lambda-microvm-source.zip` + +Upload the zip to S3 and use it as the source object for +`aws lambda-microvms create-microvm-image`. + +## Runtime Contract + +The image starts `/bin/lambda-microvm-lifecycle` and listens on `HOOK_PORT`, +default `9000`. This follows the AWS Claude managed-agents sample image. + +Endpoints: + +- `GET /ready` +- `POST /ready` +- `GET /validate` +- `POST /validate` +- `GET /aws/lambda-microvms/runtime/v1/ready` +- `POST /aws/lambda-microvms/runtime/v1/ready` +- `GET /aws/lambda-microvms/runtime/v1/validate` +- `POST /aws/lambda-microvms/runtime/v1/validate` +- `POST /aws/lambda-microvms/runtime/v1/run` +- `POST /aws/lambda-microvms/runtime/v1/suspend` +- `POST /aws/lambda-microvms/runtime/v1/resume` +- `POST /aws/lambda-microvms/runtime/v1/terminate` +- `POST /run` +- `POST /suspend` +- `POST /resume` +- `POST /terminate` +- `GET /health` + +`POST /run` executes `STEREOS_RUN_COMMAND` when that environment variable is +set. The request body is passed to the command as `STEREOS_RUN_PAYLOAD`. + +`POST /aws/lambda-microvms/runtime/v1/run` accepts the AWS service envelope +shape: + +```json +{"microvmId":"...","runHookPayload":"{\"version\":\"1\",\"session\":{...}}"} +``` + +The lifecycle process records the parsed `session` object as +`last_run_dispatch`, acknowledges immediately, and runs `STEREOS_RUN_COMMAND` in +a background thread with the parsed dispatch JSON as `STEREOS_RUN_PAYLOAD`. + +The default stereOS Lambda MicroVM source bundles include the Paper CLI from a +pinned public Paper Compute release artifact. They set `STEREOS_START_PAPERD=1`, +so the lifecycle process starts `paperd` when it finds the binary at +`/usr/local/bin/paperd`, `/bin/paperd`, or `/bin/paper`. Set +`STEREOS_START_PAPERD=0` in a derived image to opt out. + +## Native Shell / SSH Access + +The Lambda MicroVM source bundle includes OpenSSH server binaries and host keys +so it can be reached with the `microvmssh` ProxyCommand pattern: + +- The image includes `/usr/sbin/sshd`, `/usr/bin/echo`, and `/usr/bin/stty` + compatibility paths. +- `sshd` is not started as a daemon. +- The local `microvmssh` client opens AWS native shell ingress, runs + `stty raw -echo; exec /usr/sbin/sshd -i ...`, then lets a normal local + `ssh` client speak over that shell WebSocket. + +Launch the MicroVM with the AWS-managed `SHELL_INGRESS` connector to enable this +path. diff --git a/lambda-microvm/lifecycle/.gitignore b/lambda-microvm/lifecycle/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/lambda-microvm/lifecycle/.gitignore @@ -0,0 +1 @@ +/target diff --git a/lambda-microvm/lifecycle/Cargo.lock b/lambda-microvm/lifecycle/Cargo.lock new file mode 100644 index 0000000..4363f9a --- /dev/null +++ b/lambda-microvm/lifecycle/Cargo.lock @@ -0,0 +1,325 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lambda-microvm-lifecycle" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "snafu", + "tiny_http", + "tracing", + "tracing-subscriber", + "wait-timeout", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/lambda-microvm/lifecycle/Cargo.toml b/lambda-microvm/lifecycle/Cargo.toml new file mode 100644 index 0000000..056bff9 --- /dev/null +++ b/lambda-microvm/lifecycle/Cargo.toml @@ -0,0 +1,29 @@ +# Standalone workspace: this crate lives in the stereOS repo and is built on its +# own by Nix (buildRustPackage), not as a member of any enclosing workspace. +[workspace] + +[package] +name = "lambda-microvm-lifecycle" +version = "0.0.0" +edition = "2024" +rust-version = "1.85" +publish = false +description = "AWS Lambda MicroVM lifecycle hook server for stereOS source bundles" + +[[bin]] +name = "lambda-microvm-lifecycle" +path = "src/main.rs" + +[dependencies] +tiny_http = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +snafu = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt"] } +wait-timeout = "0.2" + +[profile.release] +strip = true +lto = true +opt-level = "z" diff --git a/lambda-microvm/lifecycle/src/command.rs b/lambda-microvm/lifecycle/src/command.rs new file mode 100644 index 0000000..59d181b --- /dev/null +++ b/lambda-microvm/lifecycle/src/command.rs @@ -0,0 +1,145 @@ +//! Execution of `STEREOS_RUN_COMMAND`. + +use std::io::Read; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use serde_json::{Value, json}; +use snafu::{ResultExt, Snafu}; +use wait_timeout::ChildExt; + +/// Failure modes for `run_command`. A missing command is not an error (it +/// yields `{"configured": false}`); only spawn/wait failures and timeouts are. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum RunCommandError { + #[snafu(display("run command timed out"))] + Timeout, + #[snafu(display("Failed to spawn run command"))] + Spawn { source: std::io::Error }, + #[snafu(display("Failed to wait on run command"))] + Wait { source: std::io::Error }, +} + +/// Run `STEREOS_RUN_COMMAND` (when set) via `/bin/bash -lc`, passing `payload` +/// as `STEREOS_RUN_PAYLOAD`, and return the Python-compatible result object. +/// +/// `command == None` yields `{"configured": false}` and never errors. Stdout +/// and stderr are each tailed to the last 4096 chars. +pub fn run_command( + command: Option<&str>, + workdir: &str, + timeout: Duration, + payload: &str, +) -> Result { + use run_command_error::*; + + let Some(command) = command else { + return Ok(json!({ "configured": false })); + }; + + let mut child = Command::new("/bin/bash") + .arg("-lc") + .arg(command) + .current_dir(workdir) + .env("STEREOS_RUN_PAYLOAD", payload) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context(SpawnSnafu)?; + + // Drain stdout/stderr on threads so a chatty command cannot deadlock by + // filling a pipe buffer while we are blocked in wait_timeout. + let mut out = child.stdout.take().expect("piped stdout"); + let mut err = child.stderr.take().expect("piped stderr"); + let out_h = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = out.read_to_end(&mut buf); + buf + }); + let err_h = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = err.read_to_end(&mut buf); + buf + }); + + let status = match child.wait_timeout(timeout).context(WaitSnafu)? { + Some(status) => status, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(RunCommandError::Timeout); + } + }; + + let stdout = out_h.join().unwrap_or_default(); + let stderr = err_h.join().unwrap_or_default(); + + Ok(json!({ + "configured": true, + "exit_code": status.code(), + "stdout": tail_chars(&String::from_utf8_lossy(&stdout), 4096), + "stderr": tail_chars(&String::from_utf8_lossy(&stderr), 4096), + })) +} + +/// Last `n` characters of `s`, matching the Python `s[-n:]` slicing. +fn tail_chars(s: &str, n: usize) -> String { + let count = s.chars().count(); + if count <= n { + s.to_string() + } else { + s.chars().skip(count - n).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unconfigured_reports_false() { + let r = run_command(None, ".", Duration::from_secs(5), "{}").unwrap(); + assert_eq!(r, json!({ "configured": false })); + } + + #[test] + fn receives_payload_and_succeeds() { + let r = run_command( + Some(r#"printf '%s' "$STEREOS_RUN_PAYLOAD""#), + ".", + Duration::from_secs(30), + "hello-payload", + ) + .unwrap(); + assert_eq!(r["configured"], json!(true)); + assert_eq!(r["exit_code"], json!(0)); + // `contains` rather than `==` tolerates any login-shell init noise. + assert!(r["stdout"].as_str().unwrap().contains("hello-payload")); + } + + #[test] + fn stdout_tailed_to_4096_chars() { + let r = run_command( + Some("for _ in $(seq 1 5000); do printf a; done"), + ".", + Duration::from_secs(60), + "", + ) + .unwrap(); + assert_eq!(r["stdout"].as_str().unwrap().chars().count(), 4096); + } + + #[test] + fn timeout_returns_timeout_error() { + let e = run_command(Some("sleep 5"), ".", Duration::from_millis(200), "").unwrap_err(); + assert!(matches!(e, RunCommandError::Timeout)); + } + + #[test] + fn nonzero_exit_code_is_reported() { + let r = run_command(Some("exit 7"), ".", Duration::from_secs(10), "").unwrap(); + assert_eq!(r["exit_code"], json!(7)); + } +} diff --git a/lambda-microvm/lifecycle/src/dispatch.rs b/lambda-microvm/lifecycle/src/dispatch.rs new file mode 100644 index 0000000..24f8045 --- /dev/null +++ b/lambda-microvm/lifecycle/src/dispatch.rs @@ -0,0 +1,126 @@ +//! Parsing of the AWS Lambda MicroVM `runHookPayload` envelope. + +use serde_json::{Value, json}; + +/// Parse the AWS `runHookPayload` envelope into the dispatch object handed to +/// `STEREOS_RUN_COMMAND`. +/// +/// Mirrors the Python `parse_run_dispatch` exactly: +/// - empty body -> `{}` +/// - invalid JSON -> `{"raw": body}` +/// - object with a truthy `runHookPayload` string -> parse the inner JSON +/// (inner parse failure -> `{"raw": }`) +/// - otherwise the envelope itself is the inner value +/// - inner object with an object `session` -> just the `session` +/// - inner object -> the inner object +/// - inner non-object -> `{"value": inner}` +pub fn parse_run_dispatch(body: &str) -> Value { + if body.is_empty() { + return json!({}); + } + + let envelope: Value = match serde_json::from_str(body) { + Ok(v) => v, + Err(_) => return json!({ "raw": body }), + }; + + let inner: Value = match envelope.get("runHookPayload") { + Some(rhp) if is_truthy(rhp) => { + // The Python calls json.loads on the value; in practice AWS always + // sends a JSON string. A non-string cannot be re-parsed, so we treat + // it as raw, matching the "could not decode" branch. + match rhp.as_str() { + Some(s) => match serde_json::from_str(s) { + Ok(v) => v, + Err(_) => return json!({ "raw": s }), + }, + None => return json!({ "raw": body }), + } + } + _ => envelope, + }; + + let session_is_object = inner.get("session").map(Value::is_object).unwrap_or(false); + if inner.is_object() { + if session_is_object { + return inner.get("session").cloned().unwrap(); + } + return inner; + } + json!({ "value": inner }) +} + +/// Python truthiness for a JSON value, used for the `runHookPayload` guard. +fn is_truthy(v: &Value) -> bool { + match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true), + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_body_is_empty_object() { + assert_eq!(parse_run_dispatch(""), json!({})); + } + + #[test] + fn invalid_json_is_raw() { + assert_eq!(parse_run_dispatch("not json"), json!({ "raw": "not json" })); + } + + #[test] + fn direct_object_returned() { + assert_eq!(parse_run_dispatch(r#"{"a":1}"#), json!({ "a": 1 })); + } + + #[test] + fn envelope_inner_parsed() { + let body = r#"{"runHookPayload":"{\"version\":\"1\",\"foo\":true}"}"#; + assert_eq!( + parse_run_dispatch(body), + json!({ "version": "1", "foo": true }) + ); + } + + #[test] + fn session_object_extracted() { + let body = r#"{"runHookPayload":"{\"session\":{\"mode\":\"smoke\"}}"}"#; + assert_eq!(parse_run_dispatch(body), json!({ "mode": "smoke" })); + } + + #[test] + fn non_object_inner_is_wrapped() { + let body = r#"{"runHookPayload":"[1,2,3]"}"#; + assert_eq!(parse_run_dispatch(body), json!({ "value": [1, 2, 3] })); + } + + #[test] + fn empty_run_hook_payload_falls_back_to_envelope() { + // "" is falsy, so the envelope itself becomes the inner value. + let body = r#"{"runHookPayload":"","x":1}"#; + assert_eq!( + parse_run_dispatch(body), + json!({ "runHookPayload": "", "x": 1 }) + ); + } + + #[test] + fn inner_parse_failure_is_raw() { + let body = r#"{"runHookPayload":"{bad"}"#; + assert_eq!(parse_run_dispatch(body), json!({ "raw": "{bad" })); + } + + #[test] + fn non_object_session_keeps_inner() { + let body = r#"{"runHookPayload":"{\"session\":\"s\",\"a\":1}"}"#; + assert_eq!(parse_run_dispatch(body), json!({ "session": "s", "a": 1 })); + } +} diff --git a/lambda-microvm/lifecycle/src/main.rs b/lambda-microvm/lifecycle/src/main.rs new file mode 100644 index 0000000..8ae9081 --- /dev/null +++ b/lambda-microvm/lifecycle/src/main.rs @@ -0,0 +1,183 @@ +//! AWS Lambda MicroVM lifecycle hook server for stereOS source bundles. +//! +//! Serves the image-build (`ready`, `validate`) and runtime (`run`, `suspend`, +//! `resume`, `terminate`) hooks AWS calls against the MicroVM, plus direct debug +//! endpoints, and optionally starts `paperd`. This is a faithful port of the +//! original `lifecycle.py` proof of concept; the HTTP contract is unchanged. + +mod command; +mod dispatch; +mod paperd; +mod server; +mod state; + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde_json::Value; +use tiny_http::{Header, Response, Server}; + +use crate::server::{Config, Effects}; +use crate::state::State; + +fn unix_now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +/// Production [`Effects`]: runs real commands and exits the process. +struct RealEffects { + state: Arc>, + run_command: Option, + workdir: String, + timeout: Duration, +} + +impl Effects for RealEffects { + fn run_command_sync(&self, payload: &str) -> Result { + command::run_command( + self.run_command.as_deref(), + &self.workdir, + self.timeout, + payload, + ) + } + + fn spawn_dispatch(&self, dispatch: Value) { + let state = self.state.clone(); + let command = self.run_command.clone(); + let workdir = self.workdir.clone(); + let timeout = self.timeout; + std::thread::spawn(move || { + // Serialize sorted, matching the Python `json.dumps(sort_keys=True)`. + let payload = serde_json::to_string(&dispatch).unwrap_or_else(|_| "{}".to_string()); + let result = match command::run_command(command.as_deref(), &workdir, timeout, &payload) + { + Ok(v) => v, + Err(command::RunCommandError::Timeout) => { + serde_json::json!({ "configured": true, "error": "run command timed out" }) + } + Err(e) => serde_json::json!({ + "configured": command.is_some(), + "error": e.to_string(), + }), + }; + state.lock().unwrap().last_run_command = Some(result); + }); + } + + fn schedule_shutdown(&self) { + std::thread::spawn(|| { + std::thread::sleep(Duration::from_millis(250)); + std::process::exit(0); + }); + } + + fn now(&self) -> f64 { + unix_now() + } +} + +fn serve_one( + mut request: tiny_http::Request, + state: &Mutex, + cfg: &Config, + effects: &dyn Effects, +) { + let method = request.method().to_string(); + let url = request.url().to_string(); + + // Read the body lossily so invalid UTF-8 cannot drop the request, matching + // the Python `decode("utf-8", errors="replace")`. + let mut buf = Vec::new(); + let _ = request.as_reader().read_to_end(&mut buf); + let body = String::from_utf8_lossy(&buf); + + let reply = server::handle(&method, &url, &body, state, cfg, effects); + let data = serde_json::to_string(&reply.body).unwrap_or_else(|_| "{}".to_string()); + let header = Header::from_bytes(&b"content-type"[..], &b"application/json"[..]) + .expect("static header is valid"); + let response = Response::from_string(data) + .with_status_code(reply.status) + .with_header(header); + if let Err(e) = request.respond(response) { + tracing::warn!(error = %e, "failed to write response"); + } +} + +fn main() { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_target(false) + .init(); + + let state = Arc::new(Mutex::new(State::new(unix_now()))); + + // Start paperd before serving (matches the Python ordering). + let start_flag = std::env::var("STEREOS_START_PAPERD").ok(); + let action = paperd::decide( + start_flag.as_deref(), + paperd::CANDIDATES, + |p| p.exists(), + paperd::AGENT_HOME, + ); + state.lock().unwrap().paperd = paperd::start(action); + + let cfg = Arc::new(Config { + mixtape: env_or("STEREOS_MIXTAPE", "unknown"), + version: env_or("STEREOS_VERSION", "unknown"), + }); + + let timeout_secs: u64 = env_or("STEREOS_RUN_TIMEOUT_SECONDS", "300") + .parse() + .unwrap_or(300); + let effects = Arc::new(RealEffects { + state: state.clone(), + run_command: std::env::var("STEREOS_RUN_COMMAND") + .ok() + .filter(|s| !s.is_empty()), + workdir: env_or("STEREOS_WORKDIR", "/home/agent/workspace"), + timeout: Duration::from_secs(timeout_secs), + }); + + let host = env_or("HOST", "0.0.0.0"); + let port: u16 = std::env::var("HOOK_PORT") + .ok() + .or_else(|| std::env::var("PORT").ok()) + .and_then(|p| p.parse().ok()) + .unwrap_or(9000); + let addr = format!("{host}:{port}"); + + tracing::info!(%addr, "starting lifecycle server"); + let server = match Server::http(&addr) { + Ok(s) => Arc::new(s), + Err(e) => { + eprintln!("failed to bind {addr}: {e}"); + std::process::exit(1); + } + }; + + loop { + let request = match server.recv() { + Ok(r) => r, + Err(e) => { + tracing::error!(error = %e, "recv failed"); + continue; + } + }; + // Thread-per-request, mirroring the Python ThreadingHTTPServer so a slow + // /run cannot block /health. + let state = state.clone(); + let cfg = cfg.clone(); + let effects = effects.clone(); + std::thread::spawn(move || { + serve_one(request, state.as_ref(), cfg.as_ref(), effects.as_ref()); + }); + } +} diff --git a/lambda-microvm/lifecycle/src/paperd.rs b/lambda-microvm/lifecycle/src/paperd.rs new file mode 100644 index 0000000..2ff0a8a --- /dev/null +++ b/lambda-microvm/lifecycle/src/paperd.rs @@ -0,0 +1,210 @@ +//! paperd startup: a pure decision step plus a thin privilege-dropping launch. +//! +//! The agent's home and XDG directories are created and owned `1000:1000` at +//! image build time (see `lib/lambda-microvm.nix`), so this module performs no +//! `mkdir` or `chown` at runtime. It only removes a stale socket, drops to +//! uid/gid 1000, and execs. + +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub const AGENT_UID: u32 = 1000; +pub const AGENT_GID: u32 = 1000; +pub const AGENT_HOME: &str = "/home/agent"; + +/// Candidate paperd binary locations, tried in order (matches the Python). +pub const CANDIDATES: &[&str] = &["/usr/local/bin/paperd", "/bin/paperd", "/bin/paper"]; + +/// What to do about paperd, decided purely from configuration so it is testable. +#[derive(Debug, PartialEq, Eq)] +pub enum PaperdAction { + Disabled, + Missing, + Start { + binary: PathBuf, + env: Vec<(String, String)>, + }, +} + +/// Decide what to do about paperd. +/// +/// Truthiness matches the Python exactly: only `1`, `true`, or `yes` enable it. +/// `exists` is injected so binary discovery can be tested without a filesystem. +pub fn decide( + start_flag: Option<&str>, + candidates: &[&str], + exists: impl Fn(&Path) -> bool, + home: &str, +) -> PaperdAction { + let enabled = matches!(start_flag, Some("1") | Some("true") | Some("yes")); + if !enabled { + return PaperdAction::Disabled; + } + let Some(binary) = candidates.iter().map(Path::new).find(|p| exists(p)) else { + return PaperdAction::Missing; + }; + PaperdAction::Start { + binary: binary.to_path_buf(), + env: paperd_env(home), + } +} + +/// The HOME / XDG overrides paperd runs with, all under the agent home. +fn paperd_env(home: &str) -> Vec<(String, String)> { + vec![ + ("HOME".to_string(), home.to_string()), + ("XDG_CONFIG_HOME".to_string(), format!("{home}/.config")), + ("XDG_STATE_HOME".to_string(), format!("{home}/.local/state")), + ( + "XDG_RUNTIME_DIR".to_string(), + format!("{home}/.local/state"), + ), + ] +} + +/// Path of the paper daemon socket we defensively remove before launch. +pub fn socket_path(home: &str) -> PathBuf { + PathBuf::from(format!("{home}/.local/state/paper/paperd.sock")) +} + +/// Carry out `action`, returning the value to store in `State.paperd`. +pub fn start(action: PaperdAction) -> String { + match action { + PaperdAction::Disabled => { + tracing::info!("paperd startup disabled"); + "disabled".to_string() + } + PaperdAction::Missing => { + tracing::warn!("paperd binary missing"); + "missing".to_string() + } + PaperdAction::Start { binary, env } => { + // A stale socket from a prior boot would block bind. This is a file + // removal, not a permission change, so it is fine at runtime. + // Derive the home from the env we were handed, so socket cleanup and + // the daemon we launch always agree on the path. + let home = env + .iter() + .find(|(k, _)| k == "HOME") + .map(|(_, v)| v.as_str()) + .unwrap_or(AGENT_HOME); + let sock = socket_path(home); + if let Err(e) = std::fs::remove_file(&sock) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::warn!(?sock, error = %e, "could not remove stale paper socket"); + } + } + + let mut cmd = Command::new(&binary); + cmd.envs(env) + .uid(AGENT_UID) + .gid(AGENT_GID) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + // Detach into its own process group so a terminal hangup on the + // lifecycle process does not propagate to paperd. + .process_group(0); + + match cmd.spawn() { + Ok(mut child) => { + tracing::info!(binary = %binary.display(), "started paperd"); + // Reap the child when it exits so it does not linger as a + // zombie — the lifecycle process is the container's PID 1, + // which the kernel will not reap for us. + std::thread::spawn(move || { + let _ = child.wait(); + tracing::warn!("paperd exited"); + }); + "started".to_string() + } + Err(e) => { + tracing::error!(binary = %binary.display(), error = %e, "paperd startup failed"); + format!("error:{e}") + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn unset_is_disabled() { + assert_eq!( + decide(None, CANDIDATES, |_| true, AGENT_HOME), + PaperdAction::Disabled + ); + } + + #[test] + fn falsey_values_are_disabled() { + for v in ["0", "false", "no", "", "TRUE", "Yes"] { + assert_eq!( + decide(Some(v), CANDIDATES, |_| true, AGENT_HOME), + PaperdAction::Disabled, + "value {v:?} should be disabled" + ); + } + } + + #[test] + fn truthy_values_pick_first_candidate() { + for v in ["1", "true", "yes"] { + match decide(Some(v), CANDIDATES, |_| true, AGENT_HOME) { + PaperdAction::Start { binary, .. } => { + assert_eq!(binary, Path::new("/usr/local/bin/paperd")); + } + other => panic!("value {v:?}: expected Start, got {other:?}"), + } + } + } + + #[test] + fn picks_first_existing_candidate_in_order() { + let present: HashSet<&str> = ["/bin/paper"].into_iter().collect(); + match decide( + Some("1"), + CANDIDATES, + |p| present.contains(p.to_str().unwrap()), + AGENT_HOME, + ) { + PaperdAction::Start { binary, .. } => assert_eq!(binary, Path::new("/bin/paper")), + other => panic!("expected Start, got {other:?}"), + } + } + + #[test] + fn none_existing_is_missing() { + assert_eq!( + decide(Some("1"), CANDIDATES, |_| false, AGENT_HOME), + PaperdAction::Missing + ); + } + + #[test] + fn env_points_at_agent_home() { + match decide(Some("1"), CANDIDATES, |_| true, "/home/agent") { + PaperdAction::Start { env, .. } => { + let map: std::collections::HashMap<_, _> = env.into_iter().collect(); + assert_eq!(map["HOME"], "/home/agent"); + assert_eq!(map["XDG_CONFIG_HOME"], "/home/agent/.config"); + assert_eq!(map["XDG_STATE_HOME"], "/home/agent/.local/state"); + assert_eq!(map["XDG_RUNTIME_DIR"], "/home/agent/.local/state"); + } + other => panic!("expected Start, got {other:?}"), + } + } + + #[test] + fn socket_path_under_state_home() { + assert_eq!( + socket_path("/home/agent"), + PathBuf::from("/home/agent/.local/state/paper/paperd.sock") + ); + } +} diff --git a/lambda-microvm/lifecycle/src/server.rs b/lambda-microvm/lifecycle/src/server.rs new file mode 100644 index 0000000..6e45b97 --- /dev/null +++ b/lambda-microvm/lifecycle/src/server.rs @@ -0,0 +1,413 @@ +//! HTTP routing as a pure function over `(method, path, body)`. +//! +//! Side effects (running commands, scheduling shutdown, reading the clock) are +//! injected via the [`Effects`] trait so routing and state transitions are +//! unit-testable without binding a socket. + +use std::sync::Mutex; + +use serde_json::{Value, json}; + +use crate::command::RunCommandError; +use crate::dispatch::parse_run_dispatch; +use crate::state::State; + +/// Side effects the router triggers, injected for testability. +pub trait Effects: Send + Sync { + /// Run `STEREOS_RUN_COMMAND` synchronously (direct `POST /run`). + fn run_command_sync(&self, payload: &str) -> Result; + /// Run the parsed dispatch in the background (AWS `/run`). + fn spawn_dispatch(&self, dispatch: Value); + /// Schedule process shutdown shortly after responding (terminate hooks). + fn schedule_shutdown(&self); + /// Current unix time in seconds (injectable for deterministic tests). + fn now(&self) -> f64; +} + +/// Static configuration surfaced in every snapshot. +pub struct Config { + pub mixtape: String, + pub version: String, +} + +/// An HTTP response: status code plus JSON body. +pub struct Reply { + pub status: u16, + pub body: Value, +} + +const HOOK_PATHS: &[(&str, &str)] = &[ + ("/aws/lambda-microvms/runtime/v1/ready", "ready"), + ("/aws/lambda-microvms/runtime/v1/validate", "validate"), + ("/aws/lambda-microvms/runtime/v1/run", "run"), + ("/aws/lambda-microvms/runtime/v1/suspend", "suspend"), + ("/aws/lambda-microvms/runtime/v1/resume", "resume"), + ("/aws/lambda-microvms/runtime/v1/terminate", "terminate"), +]; + +fn hook_for(path: &str) -> Option<&'static str> { + HOOK_PATHS.iter().find(|(p, _)| *p == path).map(|(_, h)| *h) +} + +fn not_found() -> Reply { + Reply { + status: 404, + body: json!({ "error": "not found" }), + } +} + +/// Route a request to a reply, mutating state and triggering effects. +/// +/// This is the unit-test seam, so it takes its dependencies explicitly rather +/// than behind a context struct. +#[allow(clippy::too_many_arguments)] +pub fn handle( + method: &str, + path: &str, + body: &str, + state: &Mutex, + cfg: &Config, + effects: &dyn Effects, +) -> Reply { + let router = Router { + state, + cfg, + effects, + now: effects.now(), + }; + let path = path.split('?').next().unwrap_or(path); + match method { + "GET" => router.get(path), + "POST" => router.post(path, body), + _ => not_found(), + } +} + +/// Per-request routing context: bundles the dependencies so handlers stay +/// small and the clock is captured once per request. +struct Router<'a> { + state: &'a Mutex, + cfg: &'a Config, + effects: &'a dyn Effects, + now: f64, +} + +impl Router<'_> { + fn snapshot(&self) -> Value { + self.state + .lock() + .unwrap() + .snapshot(&self.cfg.mixtape, &self.cfg.version, self.now) + } + + fn ok(&self) -> Reply { + Reply { + status: 200, + body: self.snapshot(), + } + } + + fn get(&self, path: &str) -> Reply { + if let Some(hook @ ("ready" | "validate")) = hook_for(path) { + self.state.lock().unwrap().record_hook(hook, "", self.now); + return self.ok(); + } + match path { + "/" | "/health" | "/ready" | "/validate" => self.ok(), + _ => not_found(), + } + } + + fn post(&self, path: &str, body: &str) -> Reply { + if let Some(hook) = hook_for(path) { + self.state.lock().unwrap().record_hook(hook, body, self.now); + return Reply { + status: 200, + body: self.apply_hook(hook, body), + }; + } + + match path { + "/ready" | "/validate" => self.ok(), + "/run" => self.direct_run(body), + "/suspend" => { + self.state.lock().unwrap().suspended = true; + Reply { + status: 200, + body: json!({ "ok": true, "state": self.snapshot() }), + } + } + "/resume" => { + self.state.lock().unwrap().suspended = false; + Reply { + status: 200, + body: json!({ "ok": true, "state": self.snapshot() }), + } + } + "/terminate" => { + // Build the reply (acknowledging) before scheduling shutdown, so + // the client always sees the 200 first. + let reply = Reply { + status: 200, + body: json!({ "ok": true, "state": self.snapshot() }), + }; + self.effects.schedule_shutdown(); + reply + } + _ => not_found(), + } + } + + /// Direct `POST /run`: runs `STEREOS_RUN_COMMAND` synchronously with the raw + /// body as the payload (no envelope parsing). + fn direct_run(&self, body: &str) -> Reply { + { + let mut s = self.state.lock().unwrap(); + s.run_count += 1; + s.last_run_payload = Some(body.to_string()); + } + match self.effects.run_command_sync(body) { + Ok(command) => Reply { + status: 200, + body: json!({ "ok": true, "state": self.snapshot(), "command": command }), + }, + Err(RunCommandError::Timeout) => Reply { + status: 504, + body: json!({ "ok": false, "error": "run command timed out" }), + }, + Err(e) => Reply { + status: 500, + body: json!({ "ok": false, "error": e.to_string() }), + }, + } + } + + fn apply_hook(&self, hook: &str, body: &str) -> Value { + match hook { + "run" => { + let dispatch = parse_run_dispatch(body); + { + let mut s = self.state.lock().unwrap(); + s.run_count += 1; + s.last_run_payload = Some(body.to_string()); + s.last_run_dispatch = Some(dispatch.clone()); + } + self.effects.spawn_dispatch(dispatch); + json!({ "status": "accepted", "state": self.snapshot() }) + } + "suspend" => { + self.state.lock().unwrap().suspended = true; + json!({ "status": "ok", "state": self.snapshot() }) + } + "resume" => { + self.state.lock().unwrap().suspended = false; + json!({ "status": "ok", "state": self.snapshot() }) + } + "terminate" => { + self.effects.schedule_shutdown(); + json!({ "status": "ok", "state": self.snapshot() }) + } + // ready / validate and any future hook: acknowledge with a snapshot. + _ => json!({ "status": "ok", "state": self.snapshot() }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + enum RunMode { + Ok, + Timeout, + Err, + } + + struct Mock { + run_mode: RunMode, + dispatched: Mutex>, + shutdowns: AtomicUsize, + } + + impl Mock { + fn new(run_mode: RunMode) -> Self { + Self { + run_mode, + dispatched: Mutex::new(Vec::new()), + shutdowns: AtomicUsize::new(0), + } + } + } + + impl Effects for Mock { + fn run_command_sync(&self, payload: &str) -> Result { + match self.run_mode { + RunMode::Ok => Ok(json!({ "configured": true, "echo": payload })), + RunMode::Timeout => Err(RunCommandError::Timeout), + RunMode::Err => Err(RunCommandError::Spawn { + source: std::io::Error::other("boom"), + }), + } + } + fn spawn_dispatch(&self, dispatch: Value) { + self.dispatched.lock().unwrap().push(dispatch); + } + fn schedule_shutdown(&self) { + self.shutdowns.fetch_add(1, Ordering::SeqCst); + } + fn now(&self) -> f64 { + 1.0 + } + } + + fn ctx() -> (Mutex, Config) { + ( + Mutex::new(State::new(0.0)), + Config { + mixtape: "m".to_string(), + version: "v".to_string(), + }, + ) + } + + #[test] + fn get_ready_and_validate_are_200() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + for p in ["/ready", "/validate", "/", "/health"] { + assert_eq!(handle("GET", p, "", &st, &cfg, &m).status, 200, "{p}"); + } + } + + #[test] + fn aws_get_ready_validate_are_200_and_record_hooks() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + let r = handle( + "GET", + "/aws/lambda-microvms/runtime/v1/ready", + "", + &st, + &cfg, + &m, + ); + assert_eq!(r.status, 200); + assert_eq!(st.lock().unwrap().hooks.len(), 1); + } + + #[test] + fn aws_get_run_path_is_404() { + // GET on a non-ready/validate AWS hook path is not routed. + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + let r = handle( + "GET", + "/aws/lambda-microvms/runtime/v1/run", + "", + &st, + &cfg, + &m, + ); + assert_eq!(r.status, 404); + } + + #[test] + fn unknown_path_is_404() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + assert_eq!(handle("GET", "/nope", "", &st, &cfg, &m).status, 404); + assert_eq!(handle("POST", "/nope", "", &st, &cfg, &m).status, 404); + } + + #[test] + fn suspend_then_resume_toggles_state() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + handle("POST", "/suspend", "", &st, &cfg, &m); + assert!(st.lock().unwrap().suspended); + handle("POST", "/resume", "", &st, &cfg, &m); + assert!(!st.lock().unwrap().suspended); + } + + #[test] + fn direct_run_increments_run_count() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + let r = handle("POST", "/run", "raw-body", &st, &cfg, &m); + assert_eq!(r.status, 200); + assert_eq!(st.lock().unwrap().run_count, 1); + assert_eq!( + st.lock().unwrap().last_run_payload.as_deref(), + Some("raw-body") + ); + assert_eq!(r.body["command"]["echo"], json!("raw-body")); + } + + #[test] + fn direct_run_timeout_is_504() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Timeout); + assert_eq!(handle("POST", "/run", "x", &st, &cfg, &m).status, 504); + } + + #[test] + fn direct_run_error_is_500() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Err); + assert_eq!(handle("POST", "/run", "x", &st, &cfg, &m).status, 500); + } + + #[test] + fn aws_run_records_dispatch_and_spawns() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + let body = r#"{"runHookPayload":"{\"session\":{\"mode\":\"smoke\"}}"}"#; + let r = handle( + "POST", + "/aws/lambda-microvms/runtime/v1/run", + body, + &st, + &cfg, + &m, + ); + assert_eq!(r.status, 200); + assert_eq!(r.body["status"], json!("accepted")); + assert_eq!(st.lock().unwrap().run_count, 1); + assert_eq!( + st.lock().unwrap().last_run_dispatch, + Some(json!({ "mode": "smoke" })) + ); + assert_eq!( + m.dispatched.lock().unwrap().as_slice(), + &[json!({ "mode": "smoke" })] + ); + } + + #[test] + fn terminate_acknowledges_then_schedules_shutdown() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + let r = handle("POST", "/terminate", "", &st, &cfg, &m); + assert_eq!(r.status, 200); + assert_eq!(r.body["ok"], json!(true)); + assert_eq!(m.shutdowns.load(Ordering::SeqCst), 1); + } + + #[test] + fn aws_terminate_hook_schedules_shutdown() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + let r = handle( + "POST", + "/aws/lambda-microvms/runtime/v1/terminate", + "", + &st, + &cfg, + &m, + ); + assert_eq!(r.status, 200); + assert_eq!(r.body["status"], json!("ok")); + assert_eq!(m.shutdowns.load(Ordering::SeqCst), 1); + } +} diff --git a/lambda-microvm/lifecycle/src/state.rs b/lambda-microvm/lifecycle/src/state.rs new file mode 100644 index 0000000..51e41a3 --- /dev/null +++ b/lambda-microvm/lifecycle/src/state.rs @@ -0,0 +1,130 @@ +//! Shared lifecycle state and the JSON snapshot returned by every endpoint. + +use serde::Serialize; +use serde_json::Value; + +/// A recorded lifecycle hook invocation. +/// +/// Mirrors the Python `record_hook` event: `{hook, body, at}` where `body` is +/// truncated to 256 chars and `at` is seconds since boot. +#[derive(Debug, Clone, Serialize)] +pub struct Hook { + pub hook: String, + pub body: String, + pub at: f64, +} + +/// Mutable lifecycle state shared across request handlers. +/// +/// Field names and the serialized snapshot intentionally match the original +/// Python `STATE` dict so downstream AWS hook consumers and the demo runbook +/// observe an identical JSON shape. +#[derive(Debug)] +pub struct State { + pub booted_at: f64, + pub run_count: u64, + pub last_run_payload: Option, + pub last_run_dispatch: Option, + pub last_run_command: Option, + pub suspended: bool, + pub paperd: String, + pub hooks: Vec, +} + +impl State { + pub fn new(booted_at: f64) -> Self { + Self { + booted_at, + run_count: 0, + last_run_payload: None, + last_run_dispatch: None, + last_run_command: None, + suspended: false, + paperd: "not-started".to_string(), + hooks: Vec::new(), + } + } + + /// Build the JSON snapshot returned by every endpoint. + /// + /// `now` is the current unix time in seconds; passing it in keeps this pure + /// and testable. Keys serialize sorted (serde_json's `Map` is a `BTreeMap`), + /// matching the Python `json.dumps(..., sort_keys=True)`. + pub fn snapshot(&self, mixtape: &str, version: &str, now: f64) -> Value { + serde_json::json!({ + "booted_at": self.booted_at, + "run_count": self.run_count, + "last_run_payload": self.last_run_payload, + "last_run_dispatch": self.last_run_dispatch, + "last_run_command": self.last_run_command, + "suspended": self.suspended, + "paperd": self.paperd, + "hooks": self.hooks, + "uptime_seconds": round3(now - self.booted_at), + "mixtape": mixtape, + "version": version, + }) + } + + /// Append a hook event, truncating the recorded body to 256 chars. + pub fn record_hook(&mut self, hook: &str, body: &str, now: f64) { + let truncated: String = body.chars().take(256).collect(); + self.hooks.push(Hook { + hook: hook.to_string(), + body: truncated, + at: round3(now - self.booted_at), + }); + } +} + +/// Round to 3 decimal places, matching the Python `round(x, 3)` calls. +pub fn round3(x: f64) -> f64 { + (x * 1000.0).round() / 1000.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drift guard: the snapshot key set is a contract with AWS hook consumers + /// and the demo runbook. If this changes, that is a deliberate break. + #[test] + fn snapshot_has_exact_key_set() { + let s = State::new(100.0); + let snap = s.snapshot("coder", "1.2.3", 100.5); + let obj = snap.as_object().unwrap(); + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "booted_at", + "hooks", + "last_run_command", + "last_run_dispatch", + "last_run_payload", + "mixtape", + "paperd", + "run_count", + "suspended", + "uptime_seconds", + "version", + ] + ); + assert_eq!(obj["uptime_seconds"], serde_json::json!(0.5)); + assert_eq!(obj["mixtape"], "coder"); + assert_eq!(obj["version"], "1.2.3"); + assert_eq!(obj["paperd"], "not-started"); + } + + #[test] + fn record_hook_truncates_body_to_256() { + let mut s = State::new(0.0); + let body = "x".repeat(300); + s.record_hook("run", &body, 1.0); + assert_eq!(s.hooks.len(), 1); + assert_eq!(s.hooks[0].body.chars().count(), 256); + assert_eq!(s.hooks[0].hook, "run"); + assert_eq!(s.hooks[0].at, 1.0); + } +} diff --git a/lib/lambda-microvm.nix b/lib/lambda-microvm.nix new file mode 100644 index 0000000..c20eee2 --- /dev/null +++ b/lib/lambda-microvm.nix @@ -0,0 +1,158 @@ +{ inputs }: + +{ + mkLambdaMicrovmSource = + { pkgs + , name + , version ? "0.0.0-dev" + , agentPackages ? [] + , startPaperd ? false + }: + let + paperdEnv = if startPaperd then "ENV STEREOS_START_PAPERD=1\n" else ""; + + # Built from source by Nix using the pinned nixpkgs Rust toolchain (same + # model as buildGoModule for agentd/stereosd). images.nix builds this + # natively per target system, so no cross-compilation is involved. + # doCheck is off because the unit tests exec /bin/bash, which is absent + # from the build sandbox; `cargo test` runs in the devshell and CI. + lifecycle = pkgs.rustPlatform.buildRustPackage { + pname = "lambda-microvm-lifecycle"; + inherit version; + src = ../lambda-microvm/lifecycle; + cargoLock.lockFile = ../lambda-microvm/lifecycle/Cargo.lock; + doCheck = false; + }; + + shellCompat = pkgs.runCommand "stereos-lambda-microvm-shell-compat" {} '' + mkdir -p "$out/bin" + ln -s ${pkgs.bash}/bin/bash "$out/bin/sh" + ''; + + agentEnv = pkgs.buildEnv { + name = "stereos-lambda-microvm-env"; + paths = with pkgs; [ + shellCompat + bash + cacert + coreutils + curl + findutils + git + gnugrep + gnused + jq + less + procps + ripgrep + openssh + tree + which + ] ++ agentPackages ++ [ + lifecycle + ]; + pathsToLink = [ "/bin" "/etc" "/lib" "/share" ]; + }; + + closure = pkgs.closureInfo { rootPaths = [ agentEnv ]; }; + + rootfs = pkgs.runCommand "${name}-lambda-microvm-rootfs" { + nativeBuildInputs = [ pkgs.gnutar pkgs.coreutils ]; + } '' + set -eu + + root="$PWD/rootfs" + mkdir -p "$root"/{etc,root,tmp,run,run/sshd,usr/bin,usr/local/bin,usr/sbin,var/empty} + + # Pre-create the agent home and its XDG tree so the image is ready to go + # without any runtime mkdir/chown. paperd runs as uid/gid 1000 and writes + # into these at runtime; ownership is stamped into the tar below. + mkdir -p \ + "$root/home/agent/workspace" \ + "$root/home/agent/.config" \ + "$root/home/agent/.local/state/paper" + + while IFS= read -r path; do + mkdir -p "$root$(dirname "$path")" + cp -a "$path" "$root$path" + done < ${closure}/store-paths + + ln -s ${agentEnv}/bin "$root/bin" + + cat > "$root/etc/passwd" <<'EOF' + root:x:0:0:root:/root:/bin/bash + sshd:x:74:74:SSH privilege separation user:/var/empty:/bin/false + agent:x:1000:1000:stereOS agent:/home/agent:/bin/bash + EOF + cat > "$root/etc/group" <<'EOF' + root:x:0: + sshd:x:74: + agent:x:1000: + EOF + cat > "$root/etc/nsswitch.conf" <<'EOF' + passwd: files + group: files + hosts: files dns + EOF + + chmod 1777 "$root/tmp" + chmod 0700 "$root/root" + chmod 0755 "$root/var/empty" + chmod -R 0755 "$root/home/agent" + + mkdir -p "$root/etc/ssh" + ${pkgs.openssh}/bin/ssh-keygen -A -f "$root" + sed -ri 's/^#?PermitRootLogin.*/PermitRootLogin prohibit-password/' "$root/etc/ssh/sshd_config" 2>/dev/null || true + cat >> "$root/etc/ssh/sshd_config" <<'EOF' + PermitRootLogin prohibit-password + PasswordAuthentication no + ChallengeResponseAuthentication no + UsePAM no + PidFile /run/sshd/sshd.pid + EOF + + ln -s /bin/sshd "$root/usr/sbin/sshd" + ln -s /bin/echo "$root/usr/bin/echo" + ln -s /bin/stty "$root/usr/bin/stty" + + # Build the tar in two passes so the agent home subtree ships owned by + # uid/gid 1000 while everything else stays root-owned. tar applies the + # --owner/--group overrides regardless of the build user, so no + # privileged chown is needed and the image needs no runtime permission + # fixups. + tar --sort=name --numeric-owner --owner=0 --group=0 \ + --exclude=./home/agent \ + -C "$root" -cf "$out" . + tar --sort=name --numeric-owner --owner=1000 --group=1000 \ + -C "$root" --append -f "$out" \ + ./home/agent + ''; + in + pkgs.runCommand "${name}-lambda-microvm-source" { + inherit version; + nativeBuildInputs = [ pkgs.zip ]; + } '' + set -eu + + mkdir -p "$out/src" + cp ${rootfs} "$out/src/stereos-rootfs.tar" + cp ${../lambda-microvm/README.md} "$out/src/README.md" + + cat > "$out/src/Dockerfile" < Date: Thu, 25 Jun 2026 15:36:42 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=A7=20fix:=20autopatchelf=20the=20?= =?UTF-8?q?prebuilt=20paper=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Paper release binary is a glibc-dynamic ELF whose interpreter is the FHS /lib/ld-linux-*.so path, which does not exist in the Lambda MicroVM scratch rootfs — only the Nix store glibc does. Without patching, paper and paperd cannot execute there, so STEREOS_START_PAPERD silently fails and paperd never comes up despite being installed. Run autoPatchelfHook so the interpreter and RPATH point at the Nix glibc (the same loader the lifecycle binary uses), making paper runnable inside the bundle rootfs. --- lib/paper-bin.nix | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/paper-bin.nix b/lib/paper-bin.nix index c32adae..7110824 100644 --- a/lib/paper-bin.nix +++ b/lib/paper-bin.nix @@ -24,6 +24,14 @@ pkgs.stdenvNoCC.mkDerivation { hash = hashes.${platform}; }; + # The release binary is a glibc-dynamic ELF whose interpreter is the FHS + # /lib/ld-linux-*.so path. That path does not exist in the Lambda MicroVM + # scratch rootfs (only the Nix store glibc does), so without patching the + # binary cannot execute there. autoPatchelfHook rewrites the interpreter and + # RPATH to the Nix glibc (the same loader the lifecycle binary uses). + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + buildInputs = [ pkgs.stdenv.cc.cc.lib pkgs.glibc ]; + dontUnpack = true; installPhase = '' From 4d4fe38fd079ead50d523be2012d2d12dbe4d3fe Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Fri, 26 Jun 2026 11:50:53 -0700 Subject: [PATCH 3/3] feat: warm Claude Code's native build during image creation The first interactive `claude` on a fresh MicroVM spends ~a minute installing its ~240MB native build before it is usable. Do that work once, during the AWS `ready` build hook, so it lands in the image snapshot and every launched MicroVM starts warm. Measured cold first `claude --version`: ~58s -> ~0.5s. Adds a generic STEREOS_READY_COMMAND that the lifecycle runs once on the first `ready` hook, before responding 200 (the snapshot is taken after ready returns), as the agent user with an agent-rooted login env. Mixtapes built with `warmAgent = true` ship /usr/local/bin/stereos-warm-agent, which runs `claude install` (no auth, no paper) and pre-seeds onboarding so the first interactive run skips the theme prompt. The Firecracker snapshot is shared across every MicroVM from the image, so the script strips the per-machine ids `claude install` writes (machineID, userID) so they regenerate per-VM. Warm-up failures (e.g. no build-time network) are non-fatal: the image just ships unwarmed. --- flake/images.nix | 3 + lambda-microvm/README.md | 23 +++++++ lambda-microvm/lifecycle/src/command.rs | 87 +++++++++++++++++++++---- lambda-microvm/lifecycle/src/main.rs | 33 ++++++++++ lambda-microvm/lifecycle/src/server.rs | 61 +++++++++++++++++ lambda-microvm/lifecycle/src/state.rs | 5 ++ lib/lambda-microvm.nix | 34 +++++++++- 7 files changed, 232 insertions(+), 14 deletions(-) diff --git a/flake/images.nix b/flake/images.nix index 278f411..181bcc9 100644 --- a/flake/images.nix +++ b/flake/images.nix @@ -104,6 +104,9 @@ let ++ configs.${name}.config.stereos.agent.extraPackages ++ [ paper-bin ]; startPaperd = true; + # Warm Claude Code's native build into the snapshot during image + # creation. A no-op for mixtapes without `claude` on PATH. + warmAgent = true; }; }) mixtapeNames ); diff --git a/lambda-microvm/README.md b/lambda-microvm/README.md index 1978c1b..5cc6346 100644 --- a/lambda-microvm/README.md +++ b/lambda-microvm/README.md @@ -80,6 +80,29 @@ so the lifecycle process starts `paperd` when it finds the binary at `/usr/local/bin/paperd`, `/bin/paperd`, or `/bin/paper`. Set `STEREOS_START_PAPERD=0` in a derived image to opt out. +## Build-time Warm-up (`STEREOS_READY_COMMAND`) + +When `STEREOS_READY_COMMAND` is set, the lifecycle process runs it **once**, on +the first AWS `ready` build hook, **before** responding 200 — so its disk writes +are captured in the image snapshot and every launched MicroVM starts warm. It +runs as the agent user (uid/gid `1000`) with an agent-rooted login environment +(`HOME`/XDG under `/home/agent`), via `/bin/bash -lc`, bounded by +`STEREOS_READY_TIMEOUT_SECONDS` (default `240`, kept under the AWS +`readyTimeoutInSeconds`). The result is logged to CloudWatch, not stored in the +snapshot's `last_run_command`. + +Mixtapes built with `warmAgent = true` ship `/usr/local/bin/stereos-warm-agent` +and point `STEREOS_READY_COMMAND` at it. That script runs `claude install` to +pre-cache Claude Code's native build (no auth, no `paper`), then pre-seeds +onboarding so the first interactive run skips the theme prompt. + +Warm-up must produce only **shareable** state. The Firecracker snapshot is shared +by every MicroVM created from the image, so the script strips the per-machine +identifiers `claude install` writes (`machineID`, `userID`) so they regenerate +per-VM. Never `paper login` or otherwise bake secrets/unique state during the +build. Warm-up failures (e.g. no network during build) are non-fatal: the image +just ships unwarmed. + ## Native Shell / SSH Access The Lambda MicroVM source bundle includes OpenSSH server binaries and host keys diff --git a/lambda-microvm/lifecycle/src/command.rs b/lambda-microvm/lifecycle/src/command.rs index 59d181b..ba08fbe 100644 --- a/lambda-microvm/lifecycle/src/command.rs +++ b/lambda-microvm/lifecycle/src/command.rs @@ -1,7 +1,8 @@ //! Execution of `STEREOS_RUN_COMMAND`. use std::io::Read; -use std::process::{Command, Stdio}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, Stdio}; use std::time::Duration; use serde_json::{Value, json}; @@ -38,7 +39,7 @@ pub fn run_command( return Ok(json!({ "configured": false })); }; - let mut child = Command::new("/bin/bash") + let child = Command::new("/bin/bash") .arg("-lc") .arg(command) .current_dir(workdir) @@ -49,8 +50,72 @@ pub fn run_command( .spawn() .context(SpawnSnafu)?; - // Drain stdout/stderr on threads so a chatty command cannot deadlock by - // filling a pipe buffer while we are blocked in wait_timeout. + let (code, stdout, stderr) = collect(child, timeout)?; + Ok(json!({ + "configured": true, + "exit_code": code, + "stdout": tail_chars(&String::from_utf8_lossy(&stdout), 4096), + "stderr": tail_chars(&String::from_utf8_lossy(&stderr), 4096), + })) +} + +/// Run a build-time setup command (`STEREOS_READY_COMMAND`) as the agent user. +/// +/// Unlike [`run_command`] this drops to `uid`/`gid` and runs with a clean, +/// agent-rooted login environment (HOME/XDG under `home`), so build-time work +/// like `claude install` populates the agent's caches rather than root's. The +/// ambient process env is inherited so TLS cert vars (set in the image +/// Dockerfile) remain available for downloads. `command == None` is a no-op. +pub fn run_setup_command( + command: Option<&str>, + home: &str, + uid: u32, + gid: u32, + timeout: Duration, +) -> Result { + use run_command_error::*; + + let Some(command) = command else { + return Ok(json!({ "configured": false })); + }; + + let child = Command::new("/bin/bash") + .arg("-lc") + .arg(command) + .current_dir(home) + .uid(uid) + .gid(gid) + .env("HOME", home) + .env("USER", "agent") + .env("LOGNAME", "agent") + .env("XDG_CONFIG_HOME", format!("{home}/.config")) + .env("XDG_STATE_HOME", format!("{home}/.local/state")) + .env("XDG_CACHE_HOME", format!("{home}/.cache")) + .env("XDG_DATA_HOME", format!("{home}/.local/share")) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context(SpawnSnafu)?; + + let (code, stdout, stderr) = collect(child, timeout)?; + Ok(json!({ + "configured": true, + "exit_code": code, + "stdout": tail_chars(&String::from_utf8_lossy(&stdout), 4096), + "stderr": tail_chars(&String::from_utf8_lossy(&stderr), 4096), + })) +} + +/// Exit code (if any) plus the captured stdout and stderr of a finished command. +type CommandOutput = (Option, Vec, Vec); + +/// Wait for `child` with a timeout, draining stdout/stderr on threads so a +/// chatty command cannot deadlock by filling a pipe buffer. Returns the exit +/// code (if any) and the captured streams. +fn collect(mut child: Child, timeout: Duration) -> Result { + use run_command_error::*; + let mut out = child.stdout.take().expect("piped stdout"); let mut err = child.stderr.take().expect("piped stderr"); let out_h = std::thread::spawn(move || { @@ -73,15 +138,11 @@ pub fn run_command( } }; - let stdout = out_h.join().unwrap_or_default(); - let stderr = err_h.join().unwrap_or_default(); - - Ok(json!({ - "configured": true, - "exit_code": status.code(), - "stdout": tail_chars(&String::from_utf8_lossy(&stdout), 4096), - "stderr": tail_chars(&String::from_utf8_lossy(&stderr), 4096), - })) + Ok(( + status.code(), + out_h.join().unwrap_or_default(), + err_h.join().unwrap_or_default(), + )) } /// Last `n` characters of `s`, matching the Python `s[-n:]` slicing. diff --git a/lambda-microvm/lifecycle/src/main.rs b/lambda-microvm/lifecycle/src/main.rs index 8ae9081..1248d66 100644 --- a/lambda-microvm/lifecycle/src/main.rs +++ b/lambda-microvm/lifecycle/src/main.rs @@ -37,6 +37,9 @@ struct RealEffects { run_command: Option, workdir: String, timeout: Duration, + ready_command: Option, + ready_timeout: Duration, + agent_home: String, } impl Effects for RealEffects { @@ -79,6 +82,26 @@ impl Effects for RealEffects { }); } + fn warm_ready(&self) { + if self.ready_command.is_none() { + return; + } + tracing::info!("running ready warm-up command"); + match command::run_setup_command( + self.ready_command.as_deref(), + &self.agent_home, + paperd::AGENT_UID, + paperd::AGENT_GID, + self.ready_timeout, + ) { + // Logged, not stored in the snapshot: `last_run_command` is reserved + // for actual /run calls, and the warm output would otherwise ship in + // every MicroVM's initial state. + Ok(result) => tracing::info!(result = %result, "ready warm-up finished"), + Err(e) => tracing::warn!(error = %e, "ready warm-up failed"), + } + } + fn now(&self) -> f64 { unix_now() } @@ -137,6 +160,11 @@ fn main() { let timeout_secs: u64 = env_or("STEREOS_RUN_TIMEOUT_SECONDS", "300") .parse() .unwrap_or(300); + // Default under the AWS `readyTimeoutInSeconds` (300) so the warm-up cannot + // overrun the build's ready window and abort image creation. + let ready_timeout_secs: u64 = env_or("STEREOS_READY_TIMEOUT_SECONDS", "240") + .parse() + .unwrap_or(240); let effects = Arc::new(RealEffects { state: state.clone(), run_command: std::env::var("STEREOS_RUN_COMMAND") @@ -144,6 +172,11 @@ fn main() { .filter(|s| !s.is_empty()), workdir: env_or("STEREOS_WORKDIR", "/home/agent/workspace"), timeout: Duration::from_secs(timeout_secs), + ready_command: std::env::var("STEREOS_READY_COMMAND") + .ok() + .filter(|s| !s.is_empty()), + ready_timeout: Duration::from_secs(ready_timeout_secs), + agent_home: env_or("STEREOS_AGENT_HOME", paperd::AGENT_HOME), }); let host = env_or("HOST", "0.0.0.0"); diff --git a/lambda-microvm/lifecycle/src/server.rs b/lambda-microvm/lifecycle/src/server.rs index 6e45b97..73d768a 100644 --- a/lambda-microvm/lifecycle/src/server.rs +++ b/lambda-microvm/lifecycle/src/server.rs @@ -20,6 +20,9 @@ pub trait Effects: Send + Sync { fn spawn_dispatch(&self, dispatch: Value); /// Schedule process shutdown shortly after responding (terminate hooks). fn schedule_shutdown(&self); + /// Run the build-time `STEREOS_READY_COMMAND` synchronously. Called once, + /// during the AWS `ready` hook, before the image snapshot is taken. + fn warm_ready(&self); /// Current unix time in seconds (injectable for deterministic tests). fn now(&self) -> f64; } @@ -107,9 +110,27 @@ impl Router<'_> { } } + /// Run the build-time warm-up exactly once, on the first AWS `ready` hook, + /// before responding 200 so the result is captured in the image snapshot. + /// The state lock is released before the (slow) command runs. + fn maybe_warm_ready(&self, hook: &str) { + if hook != "ready" { + return; + } + { + let mut s = self.state.lock().unwrap(); + if s.ready_warmed { + return; + } + s.ready_warmed = true; + } + self.effects.warm_ready(); + } + fn get(&self, path: &str) -> Reply { if let Some(hook @ ("ready" | "validate")) = hook_for(path) { self.state.lock().unwrap().record_hook(hook, "", self.now); + self.maybe_warm_ready(hook); return self.ok(); } match path { @@ -121,6 +142,7 @@ impl Router<'_> { fn post(&self, path: &str, body: &str) -> Reply { if let Some(hook) = hook_for(path) { self.state.lock().unwrap().record_hook(hook, body, self.now); + self.maybe_warm_ready(hook); return Reply { status: 200, body: self.apply_hook(hook, body), @@ -228,6 +250,7 @@ mod tests { run_mode: RunMode, dispatched: Mutex>, shutdowns: AtomicUsize, + warms: AtomicUsize, } impl Mock { @@ -236,6 +259,7 @@ mod tests { run_mode, dispatched: Mutex::new(Vec::new()), shutdowns: AtomicUsize::new(0), + warms: AtomicUsize::new(0), } } } @@ -256,6 +280,9 @@ mod tests { fn schedule_shutdown(&self) { self.shutdowns.fetch_add(1, Ordering::SeqCst); } + fn warm_ready(&self) { + self.warms.fetch_add(1, Ordering::SeqCst); + } fn now(&self) -> f64 { 1.0 } @@ -296,6 +323,40 @@ mod tests { assert_eq!(st.lock().unwrap().hooks.len(), 1); } + #[test] + fn ready_hook_warms_once_validate_does_not() { + let (st, cfg) = ctx(); + let m = Mock::new(RunMode::Ok); + // First ready (GET) warms; second ready (POST) is a no-op. + handle( + "GET", + "/aws/lambda-microvms/runtime/v1/ready", + "", + &st, + &cfg, + &m, + ); + handle( + "POST", + "/aws/lambda-microvms/runtime/v1/ready", + "", + &st, + &cfg, + &m, + ); + // validate never warms. + handle( + "GET", + "/aws/lambda-microvms/runtime/v1/validate", + "", + &st, + &cfg, + &m, + ); + assert_eq!(m.warms.load(Ordering::SeqCst), 1); + assert!(st.lock().unwrap().ready_warmed); + } + #[test] fn aws_get_run_path_is_404() { // GET on a non-ready/validate AWS hook path is not routed. diff --git a/lambda-microvm/lifecycle/src/state.rs b/lambda-microvm/lifecycle/src/state.rs index 51e41a3..7ec9a3f 100644 --- a/lambda-microvm/lifecycle/src/state.rs +++ b/lambda-microvm/lifecycle/src/state.rs @@ -29,6 +29,10 @@ pub struct State { pub suspended: bool, pub paperd: String, pub hooks: Vec, + /// Whether the build-time ready warm-up has already run. Not serialized: + /// it guards a one-shot side effect and is not part of the AWS snapshot + /// contract. + pub ready_warmed: bool, } impl State { @@ -42,6 +46,7 @@ impl State { suspended: false, paperd: "not-started".to_string(), hooks: Vec::new(), + ready_warmed: false, } } diff --git a/lib/lambda-microvm.nix b/lib/lambda-microvm.nix index c20eee2..b0a2107 100644 --- a/lib/lambda-microvm.nix +++ b/lib/lambda-microvm.nix @@ -7,10 +7,37 @@ , version ? "0.0.0-dev" , agentPackages ? [] , startPaperd ? false + , warmAgent ? false }: let paperdEnv = if startPaperd then "ENV STEREOS_START_PAPERD=1\n" else ""; + # When enabled, the lifecycle runs this during the AWS `ready` build hook + # (as the agent user), so its disk writes land in the image snapshot and + # every launched MicroVM starts warm. It must not create per-VM-unique or + # secret state, since the snapshot is shared across all MicroVMs. + readyCommandEnv = + if warmAgent then "ENV STEREOS_READY_COMMAND=/usr/local/bin/stereos-warm-agent\n" else ""; + + # Pre-populates Claude Code's ~200MB native build (no auth, no `paper`), + # then pre-seeds onboarding so the first interactive run skips the theme + # prompt and strips the per-machine ids `claude install` writes so they + # regenerate per-VM. Failures are non-fatal: a build without network just + # ships unwarmed rather than failing image creation. + warmAgentScript = pkgs.writeText "stereos-warm-agent" '' + #!/bin/sh + set -u + command -v claude >/dev/null 2>&1 || { echo "claude not present; skipping warm-up"; exit 0; } + claude install || echo "claude install failed (no build-time network?); continuing" + cfg="$HOME/.claude.json" + if [ -f "$cfg" ] && command -v jq >/dev/null 2>&1; then + tmp="$(mktemp)" + jq 'del(.machineID, .userID, .firstStartTime) + {theme: "dark", hasCompletedOnboarding: true}' "$cfg" > "$tmp" \ + && mv "$tmp" "$cfg" || rm -f "$tmp" + fi + exit 0 + ''; + # Built from source by Nix using the pinned nixpkgs Rust toolchain (same # model as buildGoModule for agentd/stereosd). images.nix builds this # natively per target system, so no cross-compilation is involved. @@ -115,6 +142,11 @@ ln -s /bin/echo "$root/usr/bin/echo" ln -s /bin/stty "$root/usr/bin/stty" + ${pkgs.lib.optionalString warmAgent '' + cp ${warmAgentScript} "$root/usr/local/bin/stereos-warm-agent" + chmod 0755 "$root/usr/local/bin/stereos-warm-agent" + ''} + # Build the tar in two passes so the agent home subtree ships owned by # uid/gid 1000 while everything else stays root-owned. tar applies the # --owner/--group overrides regardless of the build user, so no @@ -147,7 +179,7 @@ NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt \\ STEREOS_MIXTAPE=${name} \\ STEREOS_VERSION=${version} - ${paperdEnv} + ${paperdEnv}${readyCommandEnv} WORKDIR /home/agent/workspace EXPOSE 9000 ENTRYPOINT ["/bin/lambda-microvm-lifecycle"]