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..181bcc9 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,28 @@ 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; + # Warm Claude Code's native build into the snapshot during image + # creation. A no-op for mixtapes without `claude` on PATH. + warmAgent = 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..5cc6346 --- /dev/null +++ b/lambda-microvm/README.md @@ -0,0 +1,119 @@ +# 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. + +## 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 +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..ba08fbe --- /dev/null +++ b/lambda-microvm/lifecycle/src/command.rs @@ -0,0 +1,206 @@ +//! Execution of `STEREOS_RUN_COMMAND`. + +use std::io::Read; +use std::os::unix::process::CommandExt; +use std::process::{Child, 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 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)?; + + 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 || { + 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); + } + }; + + 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. +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..1248d66 --- /dev/null +++ b/lambda-microvm/lifecycle/src/main.rs @@ -0,0 +1,216 @@ +//! 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, + ready_command: Option, + ready_timeout: Duration, + agent_home: String, +} + +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 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() + } +} + +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); + // 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") + .ok() + .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"); + 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..73d768a --- /dev/null +++ b/lambda-microvm/lifecycle/src/server.rs @@ -0,0 +1,474 @@ +//! 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); + /// 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; +} + +/// 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(), + } + } + + /// 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 { + "/" | "/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); + self.maybe_warm_ready(hook); + 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, + warms: AtomicUsize, + } + + impl Mock { + fn new(run_mode: RunMode) -> Self { + Self { + run_mode, + dispatched: Mutex::new(Vec::new()), + shutdowns: AtomicUsize::new(0), + warms: 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 warm_ready(&self) { + self.warms.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 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. + 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..7ec9a3f --- /dev/null +++ b/lambda-microvm/lifecycle/src/state.rs @@ -0,0 +1,135 @@ +//! 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, + /// 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 { + 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(), + ready_warmed: false, + } + } + + /// 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..b0a2107 --- /dev/null +++ b/lib/lambda-microvm.nix @@ -0,0 +1,190 @@ +{ inputs }: + +{ + mkLambdaMicrovmSource = + { pkgs + , name + , 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. + # 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" + + ${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 + # 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" <