Compare commits

...

5 Commits

Author SHA1 Message Date
joshsiv-mulga 413a0d0c69 feat(lambda): run the test-link agent over the authenticated TCP link
The lambdatestlink agent now loads its identity, seals fw_cfg and dials the
host exactly as the normal agent does, then carries the test stream over
that mutual-TLS connection instead of a device path. Nothing tenant-facing
starts until the host's first valid frame: a refusal after the handshake,
an early close or a shutdown never starts the bootstrap. Each VM serves one
invocation and then stops.

The normal agent is unchanged and still accepts no invocations. The tagged
tests join the race tier for the two packages that carry the build tag.
2026-11-02 23:50:27 +10:00
Josh Siviour eafad1a37e feat(lambda): start the bootstrap unprivileged with no_new_privs (#1452)
StartBootstrap sets no_new_privs on a dedicated locked OS thread and
starts the bootstrap from it, as uid/gid 993 with no supplementary groups
and only the environment it is given. The thread is never unlocked, so
the flag never reaches the agent's other threads, and no dynamic loader
runs before no_new_privs is set. The test-only agent start uses it
instead of running the bootstrap as root. The normal agent still accepts
no invocations.
2026-11-02 23:37:36 +10:00
Josh Siviour 22cca42bf4 feat(lambda): authenticate the guest's control connection at startup (invocation disabled) (#1451)
* feat(lambda): open the authenticated control connection at guest startup

A normal lambda-agent build now reads its per-generation identity from
fw_cfg, unloads qemu_fw_cfg and refuses to start unless the fw_cfg tree is
gone, then dials the host under the pinned mutual TLS and holds the
connection. It accepts no invocations and never starts the bootstrap: any
data from the host fails closed until the message protocol exists.

The image loads qemu_fw_cfg and the virtio_net chain and configures the
guest interface from opt/spinifex/netcfg, as other system VMs do. The
lambdatestlink build also seals fw_cfg before starting the bootstrap.

* test(lambda): bound startup test waits and report host accept errors
2026-11-02 22:37:36 +10:00
Josh Siviour 814b15097b feat(operator): record ADR-0008:S9 quiescence evidence on the deletion job (#1447)
* feat(iam): record quiescence evidence on the deletion job, with the L09 scope unresolved until the final inventory re-read

* fix(iam): cite ADR-0008:S9 as the basis for the STS consumer
2026-11-02 22:17:30 +10:00
Josh Siviour a541ee312d feat(lambda): add the runtime image, guest agent and authenticated TCP control connection (not wired) (#1448)
* feat(lambda): add the Lambda runtime image and guest agent

* fix(lambda): stop the guest agent after a timed-out invocation

* docs(lambda): declare the L-GUEST executor-to-agent contract

* refactor(vm): remove the unused virtio-serial port builder

Lambda guest control moves to TCP over the microVM's network, so no
caller needs SerialPorts. This reverts d7905107b.

* fix(lambda): drop the serial carrier from guest startup and keep virtio-net

run-agent no longer waits for a carrier device named on the kernel
command line, init loads virtio_net instead of virtio_console, and the
image keeps virtio_net's failover and net_failover dependencies. The
normal agent still refuses to start without an authenticated transport.

* feat(lambda): authenticate the guest's TCP control connection with pinned mutual TLS

hostlink loads the per-generation identity from fw_cfg and dials the host
over TLS 1.3 with ALPN spinifex-lambda-guest/1. VerifyPeer requires exactly
the pinned certificate, validates it, and checks its single URI SAN names
the expected scope and role. The L-GUEST contract now describes this
transport; message framing and fencing stay open, and the normal agent
still refuses to start.

* feat(lambda): add the host end of the guest's TCP mutual TLS connection

guestconn creates fresh host and guest identities for each environment
generation, produces the fw_cfg entries the guest reads, and accepts only
that generation's exact guest certificate. Peers on the untrusted bridge
that fail the handshake are closed and the listener keeps waiting until
its context ends; the cancel callback finishes before the deadline is
cleared so the listener can be reused.

The pinned-certificate check and the L-GUEST vocabulary move to
foundation/pki and contracts/lambda/v1 so both ends share them.

* docs(lambda): describe the shared connection vocabulary in the contract README
2026-11-02 22:09:58 +10:00
47 changed files with 4482 additions and 446 deletions
+3
View File
@@ -24,6 +24,9 @@ build/microvm/qboot.LICENSE
build/microvm/sqs-worker/vmlinuz
build/microvm/sqs-worker/initramfs.cpio.gz
build/microvm/sqs-worker/.stage.*/
build/microvm/lambda-runtime/vmlinuz
build/microvm/lambda-runtime/initramfs.cpio.gz
build/microvm/lambda-runtime/.stage.*/
build/qboot/
!spinifex/runtime/roles/spinifexui/frontend/dist/
tmp/
+17 -2
View File
@@ -169,7 +169,7 @@ go_local_srcs = $(shell GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go list
# worker as separate processes. A separate artifact from the LB image above.
SQS_WORKER_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-worker
SQS_WORKER_MICROVM_ARTIFACTS := $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_WORKER_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop) bin/nats-server bin/sqs-worker
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_WORKER_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop) bin/nats-server bin/sqs-worker
$(SQS_WORKER_MICROVM_ARTIFACTS) &: $(SQS_WORKER_MICROVM_INPUTS)
./scripts/build-sqs-store-microvm-image.sh
@@ -180,6 +180,20 @@ bin/sqs-worker: go.mod go.sum $$(call go_local_srcs,./cmd/sqs-worker)
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the SQS microVM kernel and initramfs
# The Lambda runtime microVM image. Built on demand; not shipped or installed.
LAMBDA_RUNTIME_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/lambda-runtime
LAMBDA_RUNTIME_MICROVM_ARTIFACTS := $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/vmlinuz $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/initramfs.cpio.gz
LAMBDA_RUNTIME_MICROVM_INPUTS := scripts/build-lambda-runtime-microvm-image.sh scripts/lib/microvm-image.sh $(addprefix $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/,init.sh inittab run-agent) bin/lambda-agent
$(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) &: $(LAMBDA_RUNTIME_MICROVM_INPUTS)
./scripts/build-lambda-runtime-microvm-image.sh
bin/lambda-agent: go.mod go.sum $$(call go_local_srcs,./cmd/lambda-agent)
@mkdir -p bin
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/lambda-agent
build-lambda-runtime-microvm-image: $(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) ## Build the Lambda runtime microVM kernel and initramfs (not shipped)
# qboot is the microvm firmware. The source tarball ships with each release
# because qboot is GPL-2.0.
QBOOT_COMMIT := $(shell sed -n 's/^QBOOT_COMMIT="\(.*\)"$$/\1/p' scripts/build-qboot.sh)
@@ -312,6 +326,7 @@ test-cover:
test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 SPINIFEX_SQS_R3=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s ./cmd/lambda-agent/ ./spinifex/agents/lambda/hostlink/ $(_RACEQ)
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
@@ -522,7 +537,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image build-lambda-runtime-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev
# modprobe needs a usable modules.dep; insmod by explicit path does not, so
# fall back to it. Handles .ko and .ko.gz.
load_mod() {
modprobe "$1" 2>/dev/null && return 0
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
insmod "$ko" 2>/dev/null && return 0
case "$ko" in
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
esac
done
return 1
}
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
# anything else runs. virtio_net needs net_failover and failover, and the
# insmod fallback resolves no dependencies, so load the chain bottom-up.
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
load_mod failover || true
load_mod net_failover || true
load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
ip link set lo up
# The guest's addresses come from the same netcfg entry as other system VMs.
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
i=0
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
sleep 0.04
i=$((i + 1))
done
if [ -f "$NETCFG" ]; then
# shellcheck source=/dev/null
. "$NETCFG"
for n in 0 1 2 3 4 5; do
eval mac=\$NIC${n}_MAC
[ -z "$mac" ] && continue
iface=""
for _ in 1 2 3 4 5; do
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
[ -n "$iface" ] && break
sleep 0.02
done
if [ -z "$iface" ]; then
echo "[init] no interface for MAC $mac (NIC$n)" >/dev/console
continue
fi
cidr="" gw="" default="" rdst="" rvia=""
eval cidr=\$NIC${n}_CIDR
eval gw=\$NIC${n}_GW
eval default=\$NIC${n}_DEFAULT
eval rdst=\$NIC${n}_ROUTE_DST
eval rvia=\$NIC${n}_ROUTE_VIA
ip link set "$iface" up
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
done
else
# lambda-agent then fails to reach the host and the guest powers off.
echo "[init] fw_cfg netcfg not available" >/dev/console
fi
exec /sbin/init
+6
View File
@@ -0,0 +1,6 @@
# The power button loads here, not in /init: busybox init has its SIGUSR2
# (poweroff) handler installed before sysinit runs, so a press can never reach
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
::once:/usr/local/lib/lambda-runtime/run-agent
::ctrlaltdel:/sbin/reboot
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Runs the Lambda guest agent, then powers the guest off whatever its status.
/usr/local/bin/lambda-agent >/dev/console 2>&1
status=$?
echo "[run-agent] lambda-agent exited with status $status" >/dev/console
poweroff -f
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
)
// Exit statuses: the guest refused to start, the control connection failed,
// or the host closed it.
const (
exitRefused = 2
exitLink = 1
exitClosed = 0
)
type startup struct {
fwCfgDir string
fwCfgRoot string
unload func() error
}
func defaultStartup() startup {
return startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
}
// connect loads the identity, seals fw_cfg whether or not the load succeeded,
// then dials the host. Under TLS 1.3 a host refusal is seen only on first read.
func (s startup) connect(ctx context.Context) (*tls.Conn, int, error) {
id, loadErr := hostlink.LoadIdentity(s.fwCfgDir)
if err := hostlink.SealFwCfg(s.fwCfgRoot, s.unload); err != nil {
return nil, exitRefused, errors.Join(loadErr, err)
}
if loadErr != nil {
return nil, exitRefused, loadErr
}
conn, err := hostlink.Dial(ctx, id)
if err != nil {
if ctx.Err() != nil {
err = fmt.Errorf("%w: %w", err, context.Cause(ctx))
}
return nil, exitLink, err
}
return conn, 0, nil
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"context"
"crypto/tls"
"net"
"os"
"path/filepath"
"testing"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
"github.com/stretchr/testify/require"
)
const scope lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
// guest is a fake fw_cfg tree; unload removes it as unloading the module would.
type guest struct {
root string
unloads int
sealedAt chan struct{}
}
func newGuest(t *testing.T, entries map[string][]byte) *guest {
t.Helper()
g := &guest{root: filepath.Join(t.TempDir(), "qemu_fw_cfg"), sealedAt: make(chan struct{}, 1)}
for name, b := range entries {
dir := filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix, name)
require.NoError(t, os.MkdirAll(dir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw"), b, 0o600))
}
return g
}
func (g *guest) startup() startup {
return startup{
fwCfgDir: filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix),
fwCfgRoot: g.root,
unload: func() error {
g.unloads++
g.sealedAt <- struct{}{}
return os.RemoveAll(g.root)
},
}
}
type host struct {
ln *net.TCPListener
gen *guestconn.Generation
conn chan accepted
}
func newHost(t *testing.T) *host {
t.Helper()
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
gen, err := guestconn.NewGeneration(scope, time.Hour)
require.NoError(t, err)
return &host{ln: ln, gen: gen, conn: make(chan accepted, 1)}
}
func (h *host) entries() map[string][]byte { return h.gen.GuestEntries(h.ln.Addr().String()) }
type accepted struct {
conn *tls.Conn
err error
}
// accept runs the host end until ctx ends and reports what it accepted.
func (h *host) accept(ctx context.Context) {
go func() {
c, err := h.gen.AcceptGuest(ctx, h.ln, 2*time.Second)
h.conn <- accepted{c, err}
}()
}
// guest waits for the host to authenticate the guest.
func (h *host) guest(t *testing.T) *tls.Conn {
t.Helper()
a := wait(t, h.conn, "host to authenticate the guest")
require.NoError(t, a.err)
return a.conn
}
// wait bounds every test wait so a stalled host or startup fails promptly.
func wait[T any](t *testing.T, ch <-chan T, what string) T {
t.Helper()
select {
case v := <-ch:
return v
case <-time.After(10 * time.Second):
t.Fatalf("timed out waiting for %s", what)
panic("unreachable")
}
}
+24
View File
@@ -0,0 +1,24 @@
// lambda-agent runs inside a Lambda guest.
//
// A normal build reads its per-generation identity from fw_cfg, unloads
// fw_cfg so nothing started later can read the guest key, and opens the
// authenticated control connection to the host. It accepts no invocations and
// never starts the function's bootstrap: the message protocol and grant
// fencing do not exist yet. The agent never listens for the host.
//
// A lambdatestlink build opens the same connection, then relays one invocation
// over a test carrier for local proofs; the carrier is not the guest protocol.
package main
import (
"context"
"flag"
"os"
)
func main() { os.Exit(run()) }
func run() int {
flag.Parse()
return start(context.Background())
}
+56
View File
@@ -0,0 +1,56 @@
//go:build !lambdatestlink
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log/slog"
)
// errNoProtocol is returned when the host sends anything: this build defines no
// message protocol, so it accepts no work.
var errNoProtocol = errors.New("host sent data, but this build accepts no invocations")
func start(ctx context.Context) int {
code, err := defaultStartup().run(ctx)
if err != nil {
slog.Error("lambda-agent stopping", "exit", code, "err", err)
}
return code
}
// run authenticates to the host and holds the connection until it closes.
func (s startup) run(ctx context.Context) (int, error) {
conn, code, err := s.connect(ctx)
if err != nil {
return code, err
}
defer conn.Close()
slog.Info("control connection open; invocations are not enabled in this build", "host", conn.RemoteAddr().String())
if err := awaitHostClose(ctx, conn); err != nil {
return exitLink, err
}
return exitClosed, nil
}
// awaitHostClose reads until the host closes the connection. Under TLS 1.3 a
// host that rejects the guest certificate is only seen here, on first read.
func awaitHostClose(ctx context.Context, conn *tls.Conn) error {
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
n, err := conn.Read(make([]byte, 1))
switch {
case n > 0:
return errNoProtocol
case errors.Is(err, io.EOF):
return nil
case ctx.Err() != nil:
return fmt.Errorf("control connection: %w", context.Cause(ctx))
default:
return fmt.Errorf("control connection: %w", err)
}
}
+171
View File
@@ -0,0 +1,171 @@
//go:build !lambdatestlink
package main
import (
"context"
"errors"
"net"
"os"
"testing"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type result struct {
code int
err error
}
func runAsync(ctx context.Context, s startup) <-chan result {
out := make(chan result, 1)
go func() {
code, err := s.run(ctx)
out <- result{code, err}
}()
return out
}
func TestStartupAuthenticatesAfterSealingFwCfg(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
g := newGuest(t, h.entries())
done := runAsync(ctx, g.startup())
conn := h.guest(t)
select {
case <-g.sealedAt:
default:
t.Fatal("guest dialled before fw_cfg was sealed")
}
_, err := os.Stat(g.root)
assert.ErrorIs(t, err, os.ErrNotExist, "fw_cfg still readable while connected")
require.NoError(t, conn.Close())
res := wait(t, done, "startup to return")
assert.Equal(t, exitClosed, res.code)
assert.NoError(t, res.err)
assert.Equal(t, 1, g.unloads)
}
func TestStartupRefusesHostData(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
done := runAsync(ctx, newGuest(t, h.entries()).startup())
conn := h.guest(t)
defer conn.Close()
_, err := conn.Write([]byte{0})
require.NoError(t, err)
res := wait(t, done, "startup to return")
assert.Equal(t, exitLink, res.code)
assert.ErrorIs(t, res.err, errNoProtocol)
}
// The host pins another generation's guest; the guest learns of the refusal
// on its first read and fails with a link error, never a clean close.
func TestStartupFailsWhenHostRefusesGuest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
other, err := guestconn.NewGeneration(scope, time.Hour)
require.NoError(t, err)
entries := h.entries()
wrong := other.GuestEntries(h.ln.Addr().String())
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
g := newGuest(t, entries)
res := wait(t, runAsync(ctx, g.startup()), "startup to return")
assert.Equal(t, exitLink, res.code)
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "bad certificate")
assert.Equal(t, 1, g.unloads)
select {
case a := <-h.conn:
require.Error(t, a.err, "host accepted a guest it does not pin")
default:
}
}
func TestStartupRefusesWithoutDialling(t *testing.T) {
h := newHost(t)
cases := map[string]struct {
entries map[string][]byte
unload func(g *guest) func() error
want string
}{
"missing entry": {
entries: func() map[string][]byte { e := h.entries(); delete(e, lambdav1.EntryHostCert); return e }(),
want: "read host-cert",
},
"fw_cfg stays readable": {
entries: h.entries(),
unload: func(*guest) func() error { return func() error { return errors.New("module is built in") } },
want: "fw_cfg still readable",
},
"unload claims success but tree remains": {
entries: h.entries(),
unload: func(*guest) func() error { return func() error { return nil } },
want: "unload reported success",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
g := newGuest(t, tc.entries)
s := g.startup()
if tc.unload != nil {
s.unload = tc.unload(g)
}
code, err := s.run(context.Background())
assert.Equal(t, exitRefused, code)
assert.ErrorContains(t, err, tc.want)
})
}
// Nothing dialled the host in any case above.
require.NoError(t, h.ln.SetDeadline(time.Now().Add(50*time.Millisecond)))
_, err := h.ln.Accept()
var ne net.Error
require.ErrorAs(t, err, &ne)
assert.True(t, ne.Timeout())
}
// A missing entry still seals fw_cfg: the key may be present even when
// another entry is not.
func TestStartupSealsEvenWhenIdentityIsIncomplete(t *testing.T) {
h := newHost(t)
e := h.entries()
delete(e, lambdav1.EntryHostAddr)
g := newGuest(t, e)
code, err := g.startup().run(context.Background())
assert.Equal(t, exitRefused, code)
assert.ErrorContains(t, err, "read host-addr")
assert.Equal(t, 1, g.unloads)
_, statErr := os.Stat(g.root)
assert.ErrorIs(t, statErr, os.ErrNotExist)
}
func TestStartupStopsWithContext(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
h := newHost(t)
h.accept(ctx)
done := runAsync(ctx, newGuest(t, h.entries()).startup())
conn := h.guest(t)
defer conn.Close()
reason := errors.New("guest shutting down")
cancel(reason)
res := wait(t, done, "startup to return")
assert.Equal(t, exitLink, res.code)
assert.ErrorIs(t, res.err, reason)
}
+128
View File
@@ -0,0 +1,128 @@
//go:build lambdatestlink
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/agent"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
)
const (
runtimeAddr = "127.0.0.1:9001"
taskRoot = "/var/task"
)
var handler = flag.String("handler", os.Getenv("_HANDLER"), "function handler passed to the bootstrap")
// testlink relays one invocation over the test carrier on the authenticated
// connection; the carrier is not the guest protocol.
type testlink struct {
startup
runtimeAddr string
startBootstrap func(ctx context.Context, env []string) (*exec.Cmd, error)
}
func start(ctx context.Context) int {
t := testlink{
startup: defaultStartup(),
runtimeAddr: runtimeAddr,
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
return agent.StartBootstrap(ctx, taskRoot+"/bootstrap", env, os.Stdout, os.Stderr)
},
}
code, err := t.run(ctx)
if err != nil {
slog.Error("lambda-agent stopping", "exit", code, "err", err)
}
return code
}
func (t testlink) run(parent context.Context) (int, error) {
conn, code, err := t.connect(parent)
if err != nil {
return code, err
}
defer conn.Close()
link := hostlink.NewTestStream(conn)
// The handshake can return before the host refuses the guest, so nothing
// tenant-facing starts until the host's first valid frame arrives.
first, err := link.Next(parent)
if errors.Is(err, hostlink.ErrShutdown) {
return exitClosed, nil
}
if err != nil {
return exitLink, fmt.Errorf("first frame from host: %w", err)
}
if parent.Err() != nil {
return exitLink, context.Cause(parent)
}
srv := runtimeapi.New()
ln, err := net.Listen("tcp", t.runtimeAddr)
if err != nil {
return exitLink, fmt.Errorf("listen runtime api: %w", err)
}
hs := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() { _ = hs.Serve(ln) }()
defer hs.Close()
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)
cmd, err := t.startBootstrap(ctx, []string{
"AWS_LAMBDA_RUNTIME_API=" + ln.Addr().String(),
"_HANDLER=" + *handler,
"LAMBDA_TASK_ROOT=" + taskRoot,
"PATH=/bin:/sbin:/usr/bin",
})
if err != nil {
return exitLink, fmt.Errorf("start bootstrap: %w", err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
err := cmd.Wait()
cancel(fmt.Errorf("bootstrap exited: %w", err))
}()
err = agent.Run(ctx, &oneInvocation{Link: link, first: &first}, srv)
cancel(errors.New("agent stopping"))
<-exited
if err != nil {
return exitLink, err
}
return exitClosed, nil
}
// oneInvocation serves the host's first request and then stops the agent:
// each test VM runs a single invocation.
type oneInvocation struct {
hostlink.Link
first *hostlink.Request
}
var _ hostlink.Link = (*oneInvocation)(nil)
func (o *oneInvocation) Next(ctx context.Context) (hostlink.Request, error) {
if err := ctx.Err(); err != nil {
return hostlink.Request{}, err
}
if o.first == nil {
return hostlink.Request{}, hostlink.ErrShutdown
}
req := *o.first
o.first = nil
return req, nil
}
+179
View File
@@ -0,0 +1,179 @@
//go:build lambdatestlink
package main
import (
"bytes"
"context"
"io"
"net/http"
"os"
"os/exec"
"testing"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const helperEnv = "LAMBDA_AGENT_TEST_BOOTSTRAP"
// TestHelperBootstrap is the fake function bootstrap, run as a subprocess: it
// echoes one invocation through the Runtime API, then waits for the next.
func TestHelperBootstrap(t *testing.T) {
if os.Getenv(helperEnv) != "1" {
return
}
base := "http://" + os.Getenv("AWS_LAMBDA_RUNTIME_API") + "/2018-06-01/runtime/invocation/"
resp, err := http.Get(base + "next")
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
resp.Body.Close()
id := resp.Header.Get("Lambda-Runtime-Aws-Request-Id")
post, err := http.Post(base+id+"/response", "application/json", bytes.NewReader(body))
require.NoError(t, err)
post.Body.Close()
_, _ = http.Get(base + "next")
}
type bootstrapStarts struct {
n int
started chan struct{}
}
func (b *bootstrapStarts) testlink(g *guest) testlink {
b.started = make(chan struct{}, 1)
return testlink{
startup: g.startup(),
runtimeAddr: "127.0.0.1:0",
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
b.n++
b.started <- struct{}{}
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestHelperBootstrap$")
cmd.Env = append(env, helperEnv+"=1")
return cmd, cmd.Start()
},
}
}
func runTestlinkAsync(ctx context.Context, tl testlink) <-chan result {
out := make(chan result, 1)
go func() {
code, err := tl.run(ctx)
out <- result{code, err}
}()
return out
}
type result struct {
code int
err error
}
func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
conn := h.guest(t)
defer conn.Close()
select {
case <-b.started:
t.Fatal("bootstrap started before the host sent a frame")
case <-time.After(100 * time.Millisecond):
}
hs := hostlink.NewTestStream(conn)
req := hostlink.Request{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{"a":40,"b":2}`)}
require.NoError(t, hs.HostSend(req))
got, err := hs.HostResult(ctx)
require.NoError(t, err)
assert.Equal(t, hostlink.Response{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", Payload: req.Payload}, got)
// A second invocation on the same VM is never served. The agent may
// already have closed the connection, so a failed send is a refusal too.
second := req
second.RequestID = "r-2"
sendErr := hs.HostSend(second)
res := wait(t, done, "agent to stop after one invocation")
assert.Equal(t, exitClosed, res.code)
assert.NoError(t, res.err)
if sendErr == nil {
_, err = hs.HostResult(ctx)
assert.Error(t, err, "agent answered a second invocation")
}
assert.Equal(t, 1, b.n)
}
func TestTestlinkShutdownBeforeInvocationNeverStartsBootstrap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
conn := h.guest(t)
defer conn.Close()
require.NoError(t, hostlink.NewTestStream(conn).HostShutdown())
res := wait(t, done, "agent to stop")
assert.Equal(t, exitClosed, res.code)
assert.NoError(t, res.err)
assert.Zero(t, b.n)
}
// The guest's handshake completes, but the host pins another generation's
// guest; the refusal arrives on first read and the bootstrap never starts.
func TestTestlinkHostRefusalNeverStartsBootstrap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
other, err := guestconn.NewGeneration(scope, time.Hour)
require.NoError(t, err)
entries := h.entries()
wrong := other.GuestEntries(h.ln.Addr().String())
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
var b bootstrapStarts
res := wait(t, runTestlinkAsync(ctx, b.testlink(newGuest(t, entries))), "agent to stop")
assert.Equal(t, exitLink, res.code)
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "bad certificate")
assert.Zero(t, b.n)
}
func TestTestlinkHostCloseBeforeFrameNeverStartsBootstrap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
require.NoError(t, h.guest(t).Close())
res := wait(t, done, "agent to stop")
assert.Equal(t, exitLink, res.code)
assert.ErrorContains(t, res.err, "first frame from host")
assert.Zero(t, b.n)
}
// A cancelled context is reported before the buffered request is delivered,
// and the request stays undelivered.
func TestOneInvocationHonoursCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
o := &oneInvocation{first: &hostlink.Request{RequestID: "r-1"}}
_, err := o.Next(ctx)
require.ErrorIs(t, err, context.Canceled)
req, err := o.Next(context.Background())
require.NoError(t, err)
assert.Equal(t, "r-1", req.RequestID)
}
+66
View File
@@ -0,0 +1,66 @@
# Lambda v1 contracts
This package will own the Lambda process contracts declared by ADR-0009:S13.
It holds the L-GUEST declaration and, in `guest.go`, the connection vocabulary both ends share: the scope, the URI SAN roles, the ALPN identifier and the fw_cfg entry names. The transport and authentication are decided (ADR-0009:S17); the invocation message types and framing are not chosen, so no message types are defined here.
It is not an AWS tenant-facing API.
## L-GUEST: node executor to guest agent
**Owner:** the Lambda domain.
**Producers and consumers:** the node-local executor (`runtime/compute/lambda`) and the in-guest Lambda agent (`agents/lambda`), in both directions.
**Operations:** bootstrap with scoped material, readiness, invoke, completion, bounded logs, shutdown.
**Transport:** TCP over the microVM's network (ADR-0009:S17).
The agent dials one connection to a listener the executor owns for the environment, and the guest exposes no listener for host control.
At most one authenticated control connection is active per environment, and the agent may reconnect; how a prior connection and its in-flight work are handled is open (ADR-0009 M1).
The network is shared and untrusted: no source address, port or bridge membership is identity or authority.
Tenant code may be able to reach the listener, but it cannot open the agent's connection or key material, so any connection it makes fails authentication.
**Authentication:** mutual TLS 1.3, bound to the environment ID and realization generation, using material the user runtime cannot read (ADR-0009:S17).
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake.
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
| Entry | Content |
|---|---|
| `guest-key` | Guest private key, PEM |
| `guest-cert` | Guest certificate, PEM |
| `host-cert` | The one pinned host certificate, PEM |
| `expect` | `<Region>/<account>/<function ID>/<environment ID>/<generation>` |
| `host-addr` | Host listener as `host:port` |
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
**Authority:** one environment's attempt only.
The user runtime never holds these keys, and sharing the guest with the agent gives user code no platform authority.
**Idempotency and fencing:** an invoke carries the attempt-grant identity and the attempt ID.
The agent accepts a run command only under a current grant, and a completion under a superseded grant is discarded and reported.
How a grant is enforced and proven ended is open (ADR-0009 M1).
**Errors and versions:** the agent and executor fail closed on an unknown protocol version or an identity mismatch.
**Readiness:** environment readiness needs authenticated, current-generation agent evidence; a running hypervisor process does not count.
**Payloads:** the contract must carry a synchronous payload and response of up to 6 MB and an asynchronous payload of up to 1 MB, plus framing, over a path whose size bound is declared.
How payloads move is open (ADR-0009 M3).
### Compatibility boundary
The agent ships inside the runtime image pinned by each published version, so it is outside the coordinated release train.
The executor supports every L-GUEST major carried by a still-supported runtime image.
Retiring an image follows a published runtime-deprecation lifecycle that blocks create and update before it blocks invoke.
Evidence required before the first agent image ships: a function on an old image invoked by a new executor, and the reverse during rollback.
## L-RUNTIME
The guest agent serves the AWS Lambda Runtime API, version `2018-06-01`, to the user runtime at `AWS_LAMBDA_RUNTIME_API`.
Spinifex does not version this AWS contract, and conformance is measured against AWS.
+54
View File
@@ -0,0 +1,54 @@
// Package lambdav1 holds the Lambda process-contract vocabulary shared by the
// node executor and the guest agent. See README.md for the declarations.
package lambdav1
import (
"fmt"
"strings"
)
// ALPNv1 names L-GUEST major version 1; a peer offering any other fails the handshake.
const ALPNv1 = "spinifex-lambda-guest/1"
// FwCfgPrefix prefixes every fw_cfg entry the executor passes the guest.
const FwCfgPrefix = "opt/spinifex/lambda/"
// fw_cfg entry names under FwCfgPrefix.
const (
EntryGuestKey = "guest-key"
EntryGuestCert = "guest-cert"
EntryHostCert = "host-cert"
EntryScope = "expect"
EntryHostAddr = "host-addr"
)
// Roles named as the last URI SAN path segment.
const (
RoleHost = "host"
RoleGuest = "guest"
)
const uriScheme = "spinifex-lambda"
// Scope names one environment generation:
// Region/account/function ID/environment ID/generation.
type Scope string
// ParseScope accepts exactly five non-empty, slash-separated segments.
func ParseScope(s string) (Scope, error) {
parts := strings.Split(s, "/")
if len(parts) != 5 {
return "", fmt.Errorf("lambdav1: scope %q: want 5 segments, got %d", s, len(parts))
}
for _, p := range parts {
if p == "" || strings.ContainsAny(p, " \t\r\n?#%") {
return "", fmt.Errorf("lambdav1: scope %q: empty or invalid segment", s)
}
}
return Scope(s), nil
}
// URI is the single URI SAN a certificate for role in this scope carries.
func (s Scope) URI(role string) string {
return uriScheme + "://" + string(s) + "/" + role
}
+20
View File
@@ -0,0 +1,20 @@
package lambdav1
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseScope(t *testing.T) {
s, err := ParseScope("ap-southeast-2/000000000001/fn-1/env-1/gen-1")
require.NoError(t, err)
assert.Equal(t, "spinifex-lambda://ap-southeast-2/000000000001/fn-1/env-1/gen-1/host", s.URI(RoleHost))
for _, bad := range []string{
"", "a/b/c/d", "a/b/c/d/e/f", "a//c/d/e", "a/b/c/d/e/", "a/b/c/d/e?x", "a/b/c/d/e#x", "a b/b/c/d/e",
} {
_, err := ParseScope(bad)
assert.Error(t, err, bad)
}
}
+62
View File
@@ -74,3 +74,65 @@ type ListAccountsResponse struct {
Accounts []AccountSummary `json:"accounts"`
Count int `json:"count"`
}
// IAMQuiescence is the coordinator's recorded view of IAM quiescence for one
// fence. Recording only: it admits nothing, and QuiescedScope stays empty until
// a final inventory re-read exists to catch a late write.
type IAMQuiescence struct {
OperationID string `json:"operationId"`
Fence uint64 `json:"fence"`
Generation uint64 `json:"generation"`
QuiescedScope []string `json:"quiescedScope"`
Unresolved []UnresolvedScope `json:"unresolved"`
Consumers []ConsumerBasis `json:"consumers"`
Writers []AccountedWriter `json:"writers"`
Inventory []InventoryStatus `json:"inventory"`
EvaluatedAt string `json:"evaluatedAt"`
}
// UnresolvedScope names a scope or writer that blocks quiescence, with a fixed
// reason. It is never a timeout.
type UnresolvedScope struct {
Scope string `json:"scope"`
Class string `json:"class"`
Reason string `json:"reason"`
}
// ConsumerBasis cites an enforcement consumer and the accepted contract that
// lets it count as having applied the generation.
type ConsumerBasis struct {
Consumer string `json:"consumer"`
Basis string `json:"basis"`
}
// AccountedWriter is a writer that cannot be fenced and is covered by the
// owner's inventory instead. Ref names the tracked work that closes the gap.
type AccountedWriter struct {
Writer string `json:"writer"`
Basis string `json:"basis"`
Ref string `json:"ref"`
}
// InventoryStatus is the owner inventory's status for one kind at evaluation.
type InventoryStatus struct {
Kind string `json:"kind"`
Status string `json:"status"`
}
// Classes of an UnresolvedScope.
const (
UnresolvedClassFence = "fence"
UnresolvedClassConsumer = "consumer"
UnresolvedClassWriter = "writer"
)
// Fixed reasons of an UnresolvedScope.
const (
ReasonFenceMismatch = "fence-mismatch"
ReasonIAMReadbackFailed = "iam-readback-failed"
ReasonNoAcceptedEnforcement = "no-accepted-enforcement-contract"
ReasonNoCompleteOwnerInventory = "no-complete-owner-inventory"
ReasonParticipantNotDeclared = "participant-not-declared"
ReasonOwnerInventoryIncomplete = "owner-inventory-incomplete"
ReasonFinalInventoryRereadNotWired = "final-inventory-reread-not-wired"
)
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# build-lambda-runtime-microvm-image.sh — build the Lambda runtime microVM kernel
# + initramfs: a guest running the Lambda guest agent, with virtio-net for its
# host control connection. It is not shipped.
#
# Inputs:
# $REPO_ROOT/build/microvm/lambda-runtime/{init.sh,inittab,run-agent}
# $REPO_ROOT/bin/lambda-agent (make bin/lambda-agent)
#
# Outputs:
# $REPO_ROOT/build/microvm/lambda-runtime/vmlinuz
# $REPO_ROOT/build/microvm/lambda-runtime/initramfs.cpio.gz
#
# Rootfs strategy and module stripping: scripts/lib/microvm-image.sh.
# shellcheck disable=SC2034 # variables are consumed by the sourced library
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm/lambda-runtime"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
RUN_AGENT="$BUILD_DIR/run-agent"
LAMBDA_AGENT_BIN="$REPO_ROOT/bin/lambda-agent"
TAG="[build-lambda-runtime-microvm-image]"
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
# kmod: the image's own depmod/modprobe rebuild module metadata. busybox ships ip.
ALPINE_PACKAGES="busybox linux-virt kmod ca-certificates"
# tiny_power_button turns the ACPI power button into a signal busybox init
# handles as poweroff.
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-tiny_power_button}"
# virtio_net needs failover and net_failover, which the virtio* glob misses.
MICROVM_EXTRA_KEEP_GLOBS=("*failover*.ko*")
MICROVM_GATE_MODULES="qemu_fw_cfg virtio_net"
# shellcheck source=lib/microvm-image.sh
source "$REPO_ROOT/scripts/lib/microvm-image.sh"
echo "$TAG repo root: $REPO_ROOT"
echo "$TAG build dir: $BUILD_DIR"
microvm_check_tools
for f in "$INIT_SH" "$INITTAB" "$RUN_AGENT"; do
if [ ! -f "$f" ]; then
echo "ERROR: $f not found — cannot build initramfs" >&2
exit 1
fi
done
if [ ! -x "$LAMBDA_AGENT_BIN" ]; then
echo "ERROR: $LAMBDA_AGENT_BIN not found — run 'make bin/lambda-agent'" >&2
exit 1
fi
if ! file -b "$LAMBDA_AGENT_BIN" | grep -q "statically linked"; then
echo "ERROR: $LAMBDA_AGENT_BIN is not statically linked — it cannot run on the musl guest" >&2
exit 1
fi
microvm_init_dirs
microvm_make_rootfs
microvm_empty_dev
echo "$TAG installing init, inittab, run-agent and lambda-agent..."
install -m 0755 "$INIT_SH" "$CHROOT_DIR/init"
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
mkdir -p "$CHROOT_DIR/usr/local/lib/lambda-runtime" "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$RUN_AGENT" "$CHROOT_DIR/usr/local/lib/lambda-runtime/run-agent"
install -m 0755 "$LAMBDA_AGENT_BIN" "$CHROOT_DIR/usr/local/bin/lambda-agent"
# Function-code mountpoint and scratch space ship empty.
mkdir -p "$CHROOT_DIR/var/task" "$CHROOT_DIR/tmp" \
"$CHROOT_DIR/proc" "$CHROOT_DIR/sys" "$CHROOT_DIR/dev" "$CHROOT_DIR/etc/ssl/certs"
# The bootstrap runs unprivileged, so / must be traversable (mktemp -d makes it
# 0700) and nothing it runs from may be group-writable.
chmod 0755 "$CHROOT_DIR" "$CHROOT_DIR/var/task" "$CHROOT_DIR/usr/local/lib/lambda-runtime"
microvm_locate_kernel
microvm_strip_modules
microvm_strip_rootfs
microvm_stage_kernel
microvm_pack_initramfs
# Nothing reaches $BUILD_DIR until the staged pair passes these checks.
for out in vmlinuz initramfs.cpio.gz; do
if [ ! -s "$STAGE_DIR/$out" ]; then
echo "ERROR: staged $out is missing or empty" >&2
exit 1
fi
done
if ! gzip -t "$STAGE_DIR/initramfs.cpio.gz"; then
echo "ERROR: staged initramfs.cpio.gz is not valid gzip" >&2
exit 1
fi
mv -f "$STAGE_DIR/vmlinuz" "$BUILD_DIR/vmlinuz"
mv -f "$STAGE_DIR/initramfs.cpio.gz" "$BUILD_DIR/initramfs.cpio.gz"
echo ""
echo "$TAG done."
+18 -347
View File
@@ -18,6 +18,7 @@
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
# 2. docker available → run alpine container, export filesystem
# 3. podman available → same via podman
# shellcheck disable=SC2034 # variables are consumed by the sourced library
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -44,21 +45,19 @@ ALPINE_PACKAGES="busybox linux-virt iproute2 ca-certificates e2fsprogs kmod"
# button (QMP system_powerdown) into a signal busybox init handles as poweroff.
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-ext4 tiny_power_button}"
# Module file globs kept on top of virtio*/*fw_cfg*: virtio_net deps for live migration.
MICROVM_EXTRA_KEEP_GLOBS=("*failover*.ko*")
# Modules the image modprobe must resolve, beyond the extras.
MICROVM_GATE_MODULES="qemu_fw_cfg virtio_net"
# shellcheck source=lib/microvm-image.sh
source "$REPO_ROOT/scripts/lib/microvm-image.sh"
echo "$TAG repo root: $REPO_ROOT"
echo "$TAG build dir: $BUILD_DIR"
mkdir -p "$BUILD_DIR"
# --- Verify non-negotiable tools ---
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
# faked-root environment so no real device node is ever created on the host
# filesystem (which previously required sudo mknod and risked clobbering host
# /dev/null on cleanup).
for tool in cpio gzip find fakeroot; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool not found: $tool" >&2
exit 1
fi
done
microvm_check_tools
if [ ! -x "$SQS_WORKER_BIN" ]; then
echo "ERROR: $SQS_WORKER_BIN not found — run 'make bin/sqs-worker'" >&2
@@ -90,100 +89,11 @@ if [ "$nats_server_version" != "$NATS_SERVER_PIN" ]; then
fi
echo "$TAG nats-server: $nats_server_version (matches go.mod)"
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
# is not a freshly-created temp dir.
case "${CHROOT_DIR:?mktemp -d returned empty}" in
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
esac
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
CONTAINER_TOOL=""
CONTAINER_CID=""
cleanup() {
echo "$TAG cleaning up chroot: $CHROOT_DIR"
rm -rf "$CHROOT_DIR"
rm -rf "${STAGE_DIR:?}"
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
echo "$TAG cleaning up container: $CONTAINER_CID"
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
microvm_init_dirs
# --- Populate rootfs ---
# Strategy 1: native apk (Alpine hosts, CI)
if command -v apk >/dev/null 2>&1; then
echo "$TAG rootfs: native apk"
mkdir -p \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/lib/apk/db" \
"$CHROOT_DIR/var/cache/apk"
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
EOF
apk add \
--root "$CHROOT_DIR" \
--arch x86_64 \
--no-cache \
--allow-untrusted \
--initdb \
$ALPINE_PACKAGES
microvm_make_rootfs
# Strategy 2: docker -> BuildKit local export.
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
# container's overlay mount (the mount survives `docker rm`), orphaning layers
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
# this grows without bound. `buildx --output type=local` writes the image
# filesystem straight to a directory and releases every mount, so nothing leaks.
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
echo "$TAG rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
BUILD_CTX=$(mktemp -d)
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ${ALPINE_PACKAGES}
DOCKERFILE
docker buildx build \
--platform linux/amd64 \
--output "type=local,dest=${CHROOT_DIR}" \
"$BUILD_CTX"
rm -rf "$BUILD_CTX"
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
# overlay2 export-mount leak, and podman has no buildx.
elif command -v podman >/dev/null 2>&1; then
echo "$TAG rootfs: podman export (alpine:${ALPINE_VERSION})"
CONTAINER_TOOL=podman
CONTAINER_CID=$(podman run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$(podman wait "$CONTAINER_CID")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
exit 1
fi
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
# --- /dev: empty mountpoint only ---
# tar extraction without root silently creates 0-byte regular files for device
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
# the cpio archive under fakeroot at pack time — NOT created on the host. init
# mounts devtmpfs over /dev as its first action for everything else at runtime.
rm -rf "${CHROOT_DIR:?}/dev"
mkdir "$CHROOT_DIR/dev"
microvm_empty_dev
# --- Place init script ---
echo "$TAG installing init.sh as /init..."
@@ -227,30 +137,8 @@ mkdir -p \
"$CHROOT_DIR/dev" \
"$CHROOT_DIR/etc/ssl/certs"
# --- Locate kernel ---
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
if [ -z "$KERNEL_IMG" ]; then
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
exit 1
fi
echo "$TAG kernel image: $KERNEL_IMG"
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
# --- Assert fw_cfg module ---
if ! find "$CHROOT_DIR/lib/modules" \
-name "qemu_fw_cfg.ko*" \
-o -name "fw_cfg_sysfs.ko*" \
| grep -q .; then
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
exit 1
fi
echo "$TAG fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
else
echo "$TAG fw_cfg: module found in lib/modules"
fi
microvm_locate_kernel
# --- Assert ext4 userspace tools (data volume) ---
for fs_tool in mkfs.ext4 e2fsck; do
@@ -261,227 +149,10 @@ for fs_tool in mkfs.ext4 e2fsck; do
done
echo "$TAG e2fsprogs: mkfs.ext4 + e2fsck present"
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
# $MICROVM_EXTRA_MODULES — ext4 by default, plus dependency closure
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
# module_path prints a module's kernel/ path from the stock modules.dep, by
# name or modules.alias; it fails when the module is not loadable.
module_path() {
local kdir="$1" name="${2//-/_}" path alias_target
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
fi
[ -n "$path" ] && printf '%s\n' "$path"
}
# module_builtin reports whether a module name is compiled into the kernel.
module_builtin() {
local kdir="$1" name="${2//-/_}"
[ -f "$kdir/modules.builtin" ] || return 1
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
}
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
# loader, so module metadata matches the guest's modprobe without a host kmod.
image_kmod() {
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
}
echo "$TAG stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
kver="$(basename "$kver_dir")"
kernel_dir="$kver_dir/kernel"
[ -d "$kernel_dir" ] || continue
if [ ! -s "$kver_dir/modules.dep" ]; then
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
exit 1
fi
# Extra modules start the closure; the glob families are kept as found.
keep_list=$(mktemp)
: > "$keep_list"
for name in $MICROVM_EXTRA_MODULES; do
if ! path=$(module_path "$kver_dir" "$name"); then
if module_builtin "$kver_dir" "$name"; then continue; fi
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
exit 1
fi
echo "$path" >> "$keep_list"
done
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
# names until the set stops growing. A softdep must be loadable or built in.
while :; do
before=$(sort -u "$keep_list" | wc -l)
for path in $(sort -u "$keep_list"); do
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
"$kver_dir/modules.dep" >> "$keep_list"
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
[ -f "$kver_dir/modules.softdep" ] || continue
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
"$kver_dir/modules.softdep"); do
if soft_path=$(module_path "$kver_dir" "$soft"); then
echo "$soft_path" >> "$keep_list"
elif ! module_builtin "$kver_dir" "$soft"; then
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
exit 1
fi
done
done
after=$(sort -u "$keep_list" | wc -l)
[ "$before" -eq "$after" ] && break
done
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
(cd "$kver_dir" && find kernel -name "virtio*.ko*" -o -name "*fw_cfg*.ko*" \
-o -name "*failover*.ko*") >> "$keep_list"
# Copy the keeper set out, preserving each kernel/ relative path.
tmp_keep=$(mktemp -d)
while read -r rel; do
rel="${rel#kernel/}"
mkdir -p "$tmp_keep/$(dirname "$rel")"
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
done < <(sort -u "$keep_list")
rm -f "$keep_list"
# Replace kernel module tree with the keeper set.
rm -rf "$kernel_dir"
mkdir -p "$kernel_dir"
# Restore each kept file into the same relative path.
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
dest_dir="$kernel_dir/$(dirname "${f#./}")"
mkdir -p "$dest_dir"
cp "$f" "$dest_dir/"
done)
rm -rf "$tmp_keep"
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
# produces an EMPTY modules.dep from compressed modules — the guest's
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
# host-kmod dependency entirely and the guest needs no decompressor either.
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
# Independent closure gate: each extra-closure module's own .modinfo depends=
# must name a kept module or a built-in, so a resolver bug fails here.
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
while read -r ko; do
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
grep -qx "$ko_name" <<< "$extra_names" || continue
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
for dep in $deps; do
dep="${dep//-/_}"
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
exit 1
fi
done
done < <(find "$kernel_dir" -name "*.ko")
for name in $MICROVM_EXTRA_MODULES; do
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
echo "ERROR: required module $name missing from the stripped module tree" >&2
exit 1
fi
done
echo "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
echo "$TAG modules kept: $(echo "$kept_names" | tr '\n' ' ')"
# Rebuild module metadata with the image's own kmod, the binary the guest's
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
# files, so they are removed first and a failed rebuild cannot ship them.
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
builtin.bin builtin.alias.bin devname; do
rm -f "$kver_dir/modules.$f"
done
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
echo "ERROR: image depmod failed for $kver" >&2
exit 1
fi
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
exit 1
fi
# Resolution gate: the image's modprobe must resolve every required module
# to plain .ko files present in the stripped tree.
for name in $MICROVM_EXTRA_MODULES qemu_fw_cfg virtio_net; do
module_builtin "$kver_dir" "$name" && continue
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
exit 1
fi
while read -r verb path _; do
[ "$verb" = "insmod" ] || continue
case "$path" in
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
esac
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
exit 1
done <<< "$plan"
echo "$TAG modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
done
done
# --- Strip package-manager artifacts (not needed at runtime) ---
echo "$TAG stripping package manager artifacts..."
rm -rf \
"$CHROOT_DIR/lib/apk" \
"$CHROOT_DIR/var/cache/apk" \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/usr/share/man" \
"$CHROOT_DIR/usr/share/doc" \
"$CHROOT_DIR/usr/share/apk" \
"$CHROOT_DIR/usr/include" \
"$CHROOT_DIR/usr/lib/pkgconfig"
# --- Strip debug symbols from binaries ---
# nats-server in /usr/local/bin is left as built (-s -w), so the shipped bytes
# are the ones whose version was checked above.
echo "$TAG stripping debug symbols from binaries..."
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
-type f 2>/dev/null | while read -r bin; do
strip --strip-all "$bin" 2>/dev/null || true
done
# --- Copy vmlinuz out before stripping /boot from chroot ---
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
echo "$TAG copying kernel: $VMLINUZ_OUT"
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
# and kernel config all live here (~25 MiB). None are needed at runtime: the
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
# is unused because we replace it with our own /init. Leaving /boot in the cpio
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
# survive decompression.
rm -rf "${CHROOT_DIR:?}/boot"
# --- Build initramfs ---
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
echo "$TAG building initramfs: $INITRAMFS_OUT"
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
fakeroot sh -c '
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
microvm_strip_modules
microvm_strip_rootfs
microvm_stage_kernel
microvm_pack_initramfs
# --- Gate the staged pair, then publish it ---
# Nothing reaches $BUILD_DIR until every gate passes, so a failed build cannot
+391
View File
@@ -0,0 +1,391 @@
#!/bin/bash
# microvm-image.sh — shared steps for the microVM image builders, sourced by
# build-sqs-store-microvm-image.sh and build-lambda-runtime-microvm-image.sh.
#
# The caller sets before use:
# TAG log prefix
# BUILD_DIR output directory; STAGE_DIR is created beside it
# ALPINE_VERSION, ALPINE_MIRROR, ALPINE_PACKAGES
# MICROVM_EXTRA_MODULES modules kept by name, with their dependency closure
# MICROVM_GATE_MODULES modules the image modprobe must resolve, beyond the extras
# MICROVM_EXTRA_KEEP_GLOBS bash array of module file globs kept on top of virtio*/*fw_cfg*
# Functions set CHROOT_DIR, STAGE_DIR, KERNEL_IMG and KERNEL_VER for the caller.
microvm_check_tools() {
# --- Verify non-negotiable tools ---
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
# faked-root environment so no real device node is ever created on the host
# filesystem (which previously required sudo mknod and risked clobbering host
# /dev/null on cleanup).
for tool in cpio gzip find fakeroot; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool not found: $tool" >&2
exit 1
fi
done
}
microvm_init_dirs() {
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
# is not a freshly-created temp dir.
case "${CHROOT_DIR:?mktemp -d returned empty}" in
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
esac
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
CONTAINER_TOOL=""
CONTAINER_CID=""
trap microvm_cleanup EXIT
}
microvm_cleanup() {
echo "$TAG cleaning up chroot: $CHROOT_DIR"
rm -rf "$CHROOT_DIR"
rm -rf "${STAGE_DIR:?}"
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
echo "$TAG cleaning up container: $CONTAINER_CID"
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
fi
}
# microvm_make_rootfs populates $CHROOT_DIR from ALPINE_PACKAGES.
microvm_make_rootfs() {
# --- Populate rootfs ---
# Strategy 1: native apk (Alpine hosts, CI)
if command -v apk >/dev/null 2>&1; then
echo "$TAG rootfs: native apk"
mkdir -p \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/lib/apk/db" \
"$CHROOT_DIR/var/cache/apk"
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
EOF
apk add \
--root "$CHROOT_DIR" \
--arch x86_64 \
--no-cache \
--allow-untrusted \
--initdb \
$ALPINE_PACKAGES
# Strategy 2: docker -> BuildKit local export.
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
# container's overlay mount (the mount survives `docker rm`), orphaning layers
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
# this grows without bound. `buildx --output type=local` writes the image
# filesystem straight to a directory and releases every mount, so nothing leaks.
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
echo "$TAG rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
BUILD_CTX=$(mktemp -d)
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ${ALPINE_PACKAGES}
DOCKERFILE
docker buildx build \
--platform linux/amd64 \
--output "type=local,dest=${CHROOT_DIR}" \
"$BUILD_CTX"
rm -rf "$BUILD_CTX"
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
# overlay2 export-mount leak, and podman has no buildx.
elif command -v podman >/dev/null 2>&1; then
echo "$TAG rootfs: podman export (alpine:${ALPINE_VERSION})"
CONTAINER_TOOL=podman
CONTAINER_CID=$(podman run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$(podman wait "$CONTAINER_CID")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
exit 1
fi
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
}
microvm_empty_dev() {
# --- /dev: empty mountpoint only ---
# tar extraction without root silently creates 0-byte regular files for device
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
# the cpio archive under fakeroot at pack time — NOT created on the host. init
# mounts devtmpfs over /dev as its first action for everything else at runtime.
rm -rf "${CHROOT_DIR:?}/dev"
mkdir "$CHROOT_DIR/dev"
}
# microvm_locate_kernel finds the kernel and asserts fw_cfg is loadable or built in.
microvm_locate_kernel() {
# --- Locate kernel ---
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
if [ -z "$KERNEL_IMG" ]; then
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
exit 1
fi
echo "$TAG kernel image: $KERNEL_IMG"
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
# --- Assert fw_cfg module ---
if ! find "$CHROOT_DIR/lib/modules" \
-name "qemu_fw_cfg.ko*" \
-o -name "fw_cfg_sysfs.ko*" \
| grep -q .; then
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
exit 1
fi
echo "$TAG fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
else
echo "$TAG fw_cfg: module found in lib/modules"
fi
}
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# $MICROVM_EXTRA_KEEP_GLOBS — caller-named module file globs
# $MICROVM_EXTRA_MODULES — caller-named modules, plus dependency closure
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
# module_path prints a module's kernel/ path from the stock modules.dep, by
# name or modules.alias; it fails when the module is not loadable.
module_path() {
local kdir="$1" name="${2//-/_}" path alias_target
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
fi
[ -n "$path" ] && printf '%s\n' "$path"
}
# module_builtin reports whether a module name is compiled into the kernel.
module_builtin() {
local kdir="$1" name="${2//-/_}"
[ -f "$kdir/modules.builtin" ] || return 1
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
}
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
# loader, so module metadata matches the guest's modprobe without a host kmod.
image_kmod() {
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
}
microvm_strip_modules() {
echo "$TAG stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
kver="$(basename "$kver_dir")"
kernel_dir="$kver_dir/kernel"
[ -d "$kernel_dir" ] || continue
if [ ! -s "$kver_dir/modules.dep" ]; then
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
exit 1
fi
# Extra modules start the closure; the glob families are kept as found.
keep_list=$(mktemp)
: > "$keep_list"
for name in $MICROVM_EXTRA_MODULES; do
if ! path=$(module_path "$kver_dir" "$name"); then
if module_builtin "$kver_dir" "$name"; then continue; fi
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
exit 1
fi
echo "$path" >> "$keep_list"
done
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
# names until the set stops growing. A softdep must be loadable or built in.
while :; do
before=$(sort -u "$keep_list" | wc -l)
for path in $(sort -u "$keep_list"); do
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
"$kver_dir/modules.dep" >> "$keep_list"
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
[ -f "$kver_dir/modules.softdep" ] || continue
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
"$kver_dir/modules.softdep"); do
if soft_path=$(module_path "$kver_dir" "$soft"); then
echo "$soft_path" >> "$keep_list"
elif ! module_builtin "$kver_dir" "$soft"; then
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
exit 1
fi
done
done
after=$(sort -u "$keep_list" | wc -l)
[ "$before" -eq "$after" ] && break
done
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
find_args=(-name "virtio*.ko*" -o -name "*fw_cfg*.ko*")
for glob in ${MICROVM_EXTRA_KEEP_GLOBS[@]+"${MICROVM_EXTRA_KEEP_GLOBS[@]}"}; do
find_args+=(-o -name "$glob")
done
(cd "$kver_dir" && find kernel "${find_args[@]}") >> "$keep_list"
# Copy the keeper set out, preserving each kernel/ relative path.
tmp_keep=$(mktemp -d)
while read -r rel; do
rel="${rel#kernel/}"
mkdir -p "$tmp_keep/$(dirname "$rel")"
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
done < <(sort -u "$keep_list")
rm -f "$keep_list"
# Replace kernel module tree with the keeper set.
rm -rf "$kernel_dir"
mkdir -p "$kernel_dir"
# Restore each kept file into the same relative path.
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
dest_dir="$kernel_dir/$(dirname "${f#./}")"
mkdir -p "$dest_dir"
cp "$f" "$dest_dir/"
done)
rm -rf "$tmp_keep"
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
# produces an EMPTY modules.dep from compressed modules — the guest's
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
# host-kmod dependency entirely and the guest needs no decompressor either.
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
# Independent closure gate: each extra-closure module's own .modinfo depends=
# must name a kept module or a built-in, so a resolver bug fails here.
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
while read -r ko; do
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
grep -qx "$ko_name" <<< "$extra_names" || continue
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
for dep in $deps; do
dep="${dep//-/_}"
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
exit 1
fi
done
done < <(find "$kernel_dir" -name "*.ko")
for name in $MICROVM_EXTRA_MODULES; do
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
echo "ERROR: required module $name missing from the stripped module tree" >&2
exit 1
fi
done
echo "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
echo "$TAG modules kept: $(echo "$kept_names" | tr '\n' ' ')"
# Rebuild module metadata with the image's own kmod, the binary the guest's
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
# files, so they are removed first and a failed rebuild cannot ship them.
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
builtin.bin builtin.alias.bin devname; do
rm -f "$kver_dir/modules.$f"
done
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
echo "ERROR: image depmod failed for $kver" >&2
exit 1
fi
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
exit 1
fi
# Resolution gate: the image's modprobe must resolve every required module
# to plain .ko files present in the stripped tree.
for name in $MICROVM_EXTRA_MODULES $MICROVM_GATE_MODULES; do
module_builtin "$kver_dir" "$name" && continue
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
exit 1
fi
while read -r verb path _; do
[ "$verb" = "insmod" ] || continue
case "$path" in
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
esac
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
exit 1
done <<< "$plan"
echo "$TAG modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
done
done
}
# microvm_strip_rootfs drops package-manager artifacts and debug symbols.
microvm_strip_rootfs() {
# --- Strip package-manager artifacts (not needed at runtime) ---
echo "$TAG stripping package manager artifacts..."
rm -rf \
"$CHROOT_DIR/lib/apk" \
"$CHROOT_DIR/var/cache/apk" \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/usr/share/man" \
"$CHROOT_DIR/usr/share/doc" \
"$CHROOT_DIR/usr/share/apk" \
"$CHROOT_DIR/usr/include" \
"$CHROOT_DIR/usr/lib/pkgconfig"
# --- Strip debug symbols from binaries ---
echo "$TAG stripping debug symbols from binaries..."
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
-type f 2>/dev/null | while read -r bin; do
strip --strip-all "$bin" 2>/dev/null || true
done
}
# microvm_stage_kernel copies vmlinuz to $STAGE_DIR, then drops /boot from the chroot.
microvm_stage_kernel() {
# --- Copy vmlinuz out before stripping /boot from chroot ---
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
echo "$TAG copying kernel: $VMLINUZ_OUT"
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
# and kernel config all live here (~25 MiB). None are needed at runtime: the
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
# is unused because we replace it with our own /init. Leaving /boot in the cpio
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
# survive decompression.
rm -rf "${CHROOT_DIR:?}/boot"
}
microvm_pack_initramfs() {
# --- Build initramfs ---
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
echo "$TAG building initramfs: $INITRAMFS_OUT"
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
fakeroot sh -c '
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
}
+91
View File
@@ -0,0 +1,91 @@
// Package agent runs the guest loop: take a request from the host link, hand
// it to the function runtime, and report the outcome back.
package agent
import (
"context"
"errors"
"fmt"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
)
const (
errTypeTimedOut = "Sandbox.Timedout"
errTypeUnknown = "Runtime.Unknown"
)
// ErrInvocationTimedOut stops the agent after it reports a timed-out
// invocation, because the runtime may still be running it.
var ErrInvocationTimedOut = errors.New("invocation timed out; environment must not serve another request")
// Run serves requests from link until the host asks for shutdown (nil), the
// runtime reports an init error, an invocation times out, ctx ends, or the
// link fails. GrantID and AttemptID are echoed to the host unchanged.
func Run(ctx context.Context, link hostlink.Link, srv *runtimeapi.Server) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
go func() {
select {
case <-srv.InitFailed():
ie, _ := srv.InitError()
cancel(fmt.Errorf("runtime init error %q: %s", ie.Type, ie.Body))
case <-ctx.Done():
}
}()
for {
req, err := link.Next(ctx)
if errors.Is(err, hostlink.ErrShutdown) {
return nil
}
if err != nil {
return cause(ctx, err)
}
res, err := srv.Invoke(ctx, runtimeapi.Invocation{
RequestID: req.RequestID,
FunctionARN: req.FunctionARN,
Deadline: time.UnixMilli(req.DeadlineMs),
TraceID: req.TraceID,
Payload: req.Payload,
})
timedOut := errors.Is(err, runtimeapi.ErrDeadlineExceeded)
if err != nil {
if ctx.Err() != nil {
return cause(ctx, err)
}
res = failure(err)
}
resp := hostlink.Response{
GrantID: req.GrantID,
AttemptID: req.AttemptID,
RequestID: req.RequestID,
Payload: res.Payload,
FunctionError: res.FunctionError,
}
if err := link.Complete(ctx, resp); err != nil {
return cause(ctx, err)
}
if timedOut {
return ErrInvocationTimedOut
}
}
}
func cause(ctx context.Context, err error) error {
if ctx.Err() != nil {
return context.Cause(ctx)
}
return err
}
func failure(err error) runtimeapi.Result {
errType := errTypeUnknown
if errors.Is(err, runtimeapi.ErrDeadlineExceeded) {
errType = errTypeTimedOut
}
return runtimeapi.Result{Payload: runtimeapi.ErrorBody(err.Error(), errType), FunctionError: errType}
}
+142
View File
@@ -0,0 +1,142 @@
package agent
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
)
const api = "/2018-06-01/runtime/"
// fakeLink feeds scripted requests and records completions.
type fakeLink struct {
reqs []hostlink.Request
got chan hostlink.Response
}
var _ hostlink.Link = (*fakeLink)(nil)
func (l *fakeLink) Next(ctx context.Context) (hostlink.Request, error) {
if len(l.reqs) == 0 {
return hostlink.Request{}, hostlink.ErrShutdown
}
r := l.reqs[0]
l.reqs = l.reqs[1:]
return r, nil
}
func (l *fakeLink) Complete(ctx context.Context, r hostlink.Response) error {
l.got <- r
return nil
}
// runtime polls next and answers using reply.
func runtime(t *testing.T, base string, reply func(id string, body []byte) (path string, out string)) {
t.Helper()
go func() {
for {
resp, err := http.Get(base + api + "invocation/next")
if err != nil {
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
id := resp.Header.Get("Lambda-Runtime-Aws-Request-Id")
path, out := reply(id, body)
r, err := http.Post(base+api+"invocation/"+id+path, "application/json", strings.NewReader(out))
if err != nil {
return
}
r.Body.Close()
}
}()
}
func deadline(d time.Duration) int64 { return time.Now().Add(d).UnixMilli() }
func TestRunServesRequestsAndEchoesIDs(t *testing.T) {
srv := runtimeapi.New()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
runtime(t, ts.URL, func(id string, body []byte) (string, string) {
if string(body) == "bad" {
return "/error", `{"errorMessage":"no"}`
}
return "/response", "echo:" + string(body)
})
link := &fakeLink{
got: make(chan hostlink.Response, 4),
reqs: []hostlink.Request{
{GrantID: "g1", AttemptID: "a1", RequestID: "r1", FunctionARN: "arn", DeadlineMs: deadline(2 * time.Second), Payload: []byte("one")},
{GrantID: "g2", AttemptID: "a2", RequestID: "r2", FunctionARN: "arn", DeadlineMs: deadline(2 * time.Second), Payload: []byte("bad")},
},
}
require.NoError(t, Run(context.Background(), link, srv))
r1, r2 := <-link.got, <-link.got
assert.Equal(t, hostlink.Response{GrantID: "g1", AttemptID: "a1", RequestID: "r1", Payload: []byte("echo:one")}, r1)
assert.Equal(t, "g2", r2.GrantID)
assert.Equal(t, "a2", r2.AttemptID)
assert.Equal(t, "Unhandled", r2.FunctionError)
}
func TestRunReportsTimeoutAndStops(t *testing.T) {
srv := runtimeapi.New() // no runtime polling: the invocation expires
link := &fakeLink{
got: make(chan hostlink.Response, 2),
reqs: []hostlink.Request{
{GrantID: "g", AttemptID: "a", RequestID: "r", DeadlineMs: deadline(30 * time.Millisecond)},
{GrantID: "g2", AttemptID: "a2", RequestID: "r2", DeadlineMs: deadline(2 * time.Second)},
},
}
require.ErrorIs(t, Run(context.Background(), link, srv), ErrInvocationTimedOut)
r := <-link.got
assert.Equal(t, "Sandbox.Timedout", r.FunctionError)
assert.Equal(t, "g", r.GrantID)
assert.Len(t, link.reqs, 1, "the next request must not be taken")
assert.Empty(t, link.got, "nothing else is reported")
}
// blockLink blocks in Next until ctx ends.
type blockLink struct{ fakeLink }
func (blockLink) Next(ctx context.Context) (hostlink.Request, error) {
<-ctx.Done()
return hostlink.Request{}, ctx.Err()
}
func TestRunStopsOnInitError(t *testing.T) {
srv := runtimeapi.New()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
r, err := http.Post(ts.URL+api+"init/error", "application/json", strings.NewReader(`{"errorMessage":"x"}`))
require.NoError(t, err)
r.Body.Close()
err = Run(context.Background(), &blockLink{}, srv)
require.Error(t, err)
assert.Contains(t, err.Error(), "init error")
}
type errLink struct{ fakeLink }
func (errLink) Next(context.Context) (hostlink.Request, error) {
return hostlink.Request{}, errors.New("link down")
}
func TestRunReturnsLinkError(t *testing.T) {
err := Run(context.Background(), &errLink{}, runtimeapi.New())
require.EqualError(t, err, "link down")
}
+54
View File
@@ -0,0 +1,54 @@
//go:build linux
package agent
import (
"context"
"fmt"
"io"
"os/exec"
goruntime "runtime"
"syscall"
"time"
"golang.org/x/sys/unix"
)
// sandboxID is the uid and gid the function's bootstrap runs as.
const sandboxID = 993
// StartBootstrap starts the function's bootstrap at path as the sandbox uid and
// gid with no supplementary groups, with no_new_privs, and with exactly env.
// The caller waits on the returned command.
func StartBootstrap(ctx context.Context, path string, env []string, stdout, stderr io.Writer) (*exec.Cmd, error) {
cred := &syscall.Credential{Uid: sandboxID, Gid: sandboxID, Groups: []uint32{}}
return startBootstrap(ctx, path, env, stdout, stderr, cred)
}
func startBootstrap(ctx context.Context, path string, env []string, stdout, stderr io.Writer, cred *syscall.Credential) (*exec.Cmd, error) {
cmd := exec.CommandContext(ctx, path)
// A nil Env inherits the agent's environment; the bootstrap gets only env.
cmd.Env = append([]string{}, env...)
cmd.Stdout, cmd.Stderr = stdout, stderr
if cred != nil {
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred}
}
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 5 * time.Second
started := make(chan error, 1)
go func() {
// no_new_privs is per thread and the child inherits it from this one. The
// thread is never unlocked, so Go ends it when this goroutine returns.
goruntime.LockOSThread()
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
started <- fmt.Errorf("set no_new_privs: %w", err)
return
}
started <- cmd.Start()
}()
if err := <-started; err != nil {
return nil, err
}
return cmd, nil
}
@@ -0,0 +1,171 @@
//go:build linux
package agent
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// statusProbe writes an executable that prints its own credential lines.
func statusProbe(t *testing.T) string {
t.Helper()
// The sandbox uid must be able to reach the probe through the test's dirs.
dir := t.TempDir()
require.NoError(t, os.Chmod(filepath.Dir(dir), 0o755))
require.NoError(t, os.Chmod(dir, 0o755))
path := filepath.Join(dir, "bootstrap")
script := "#!/bin/sh\ngrep -E '^(Uid|Gid|Groups|NoNewPrivs):' /proc/self/status\n"
require.NoError(t, os.WriteFile(path, []byte(script), 0o755))
return path
}
func statusLines(t *testing.T, out []byte) map[string]string {
t.Helper()
lines := map[string]string{}
for l := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
k, v, ok := strings.Cut(l, ":")
require.True(t, ok, "status line %q", l)
lines[k] = strings.Join(strings.Fields(v), " ")
}
return lines
}
// run starts a bootstrap with the given credential and returns its stdout.
func run(t *testing.T, path string, env []string, sandbox bool) []byte {
t.Helper()
var out bytes.Buffer
start := startBootstrap
if sandbox {
start = func(ctx context.Context, p string, e []string, o, s io.Writer, _ *syscall.Credential) (*exec.Cmd, error) {
return StartBootstrap(ctx, p, e, o, s)
}
}
cmd, err := start(context.Background(), path, env, &out, os.Stderr, nil)
require.NoError(t, err)
require.NoError(t, cmd.Wait())
return out.Bytes()
}
// threadNoNewPrivs reads no_new_privs for the calling OS thread.
func threadNoNewPrivs(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("/proc/thread-self/status")
require.NoError(t, err)
return statusLines(t, grepLines(b, "NoNewPrivs:"))["NoNewPrivs"]
}
func grepLines(b []byte, prefix string) []byte {
var keep []string
for l := range strings.SplitSeq(string(b), "\n") {
if strings.HasPrefix(l, prefix) {
keep = append(keep, l)
}
}
return []byte(strings.Join(keep, "\n"))
}
func TestStartBootstrapSetsNoNewPrivsWithoutChangingTheCaller(t *testing.T) {
goruntime.LockOSThread()
defer goruntime.UnlockOSThread()
if threadNoNewPrivs(t) != "0" {
t.Skip("test thread already has no_new_privs; the child would inherit it")
}
out := run(t, statusProbe(t), nil, false)
assert.Equal(t, "1", statusLines(t, out)["NoNewPrivs"])
assert.Equal(t, "0", threadNoNewPrivs(t), "the calling thread must keep no_new_privs unset")
}
func TestStartBootstrapDropsToSandboxIdentity(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("dropping to the sandbox uid needs root")
}
lines := statusLines(t, run(t, statusProbe(t), nil, true))
assert.Equal(t, "993 993 993 993", lines["Uid"])
assert.Equal(t, "993 993 993 993", lines["Gid"])
assert.Empty(t, lines["Groups"])
assert.Equal(t, "1", lines["NoNewPrivs"])
}
// A tenant LD_PRELOAD constructor runs before the bootstrap's main; it must
// already see no_new_privs, so no loader runs before the flag is set.
func TestStartBootstrapPreloadConstructorSeesNoNewPrivs(t *testing.T) {
goruntime.LockOSThread()
defer goruntime.UnlockOSThread()
if threadNoNewPrivs(t) != "0" {
t.Skip("test thread already has no_new_privs; the check would pass vacuously")
}
cc, err := exec.LookPath("cc")
if err != nil {
t.Skip("no C compiler for the preload library")
}
bootstrap, err := exec.LookPath("true")
require.NoError(t, err)
dir := t.TempDir()
src := filepath.Join(dir, "preload.c")
lib := filepath.Join(dir, "preload.so")
require.NoError(t, os.WriteFile(src, []byte(preloadSource), 0o644))
build := exec.Command(cc, "-shared", "-fPIC", "-o", lib, src)
build.Stderr = os.Stderr
require.NoError(t, build.Run())
out := run(t, bootstrap, []string{"LD_PRELOAD=" + lib}, false)
assert.Equal(t, "preload NoNewPrivs: 1", strings.Join(strings.Fields(string(out)), " "))
}
const preloadSource = `#include <stdio.h>
#include <string.h>
__attribute__((constructor)) static void report(void) {
char line[256];
FILE *f = fopen("/proc/self/status", "r");
if (!f)
return;
while (fgets(line, sizeof line, f))
if (strncmp(line, "NoNewPrivs:", 11) == 0)
printf("preload %s", line);
fclose(f);
fflush(stdout);
}
`
func TestStartBootstrapDoesNotInheritAgentEnvironment(t *testing.T) {
t.Setenv("ZZ_AGENT_SECRET", "must-not-leak")
probe, err := exec.LookPath("env")
require.NoError(t, err)
for name, env := range map[string][]string{"nil": nil, "empty": {}, "given": {"ONLY=this"}} {
out := run(t, probe, env, false)
// Never print the captured environment: an inherited one may hold secrets.
if strings.TrimSuffix(string(out), "\n") != strings.Join(env, "\n") {
t.Errorf("%s: bootstrap environment is not exactly the one given", name)
}
}
}
// Without root the sandbox credential cannot be applied, so the exported entry
// point must refuse rather than run the bootstrap as the caller.
func TestStartBootstrapAsNonRootRefusesInsteadOfKeepingCallerIdentity(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can apply the sandbox credential")
}
_, err := StartBootstrap(context.Background(), statusProbe(t), nil, nil, nil)
assert.ErrorIs(t, err, syscall.EPERM)
}
func TestStartBootstrapMissingExecutableFails(t *testing.T) {
_, err := startBootstrap(context.Background(), filepath.Join(t.TempDir(), "bootstrap"), nil, nil, nil, nil)
assert.Error(t, err)
}
+41
View File
@@ -0,0 +1,41 @@
// Package hostlink is the guest's boundary to host control: an authenticated
// TCP connection under per-generation mutual TLS. The message protocol over it
// is not yet chosen; implementations of Link are carriers.
package hostlink
import (
"context"
"errors"
)
// ErrShutdown is returned by Link.Next when the host asks the agent to stop.
var ErrShutdown = errors.New("hostlink: host requested shutdown")
// Request is one invocation from the host. GrantID and AttemptID are opaque
// to the agent and are echoed unchanged in the Response.
type Request struct {
GrantID string `json:"grant_id"`
AttemptID string `json:"attempt_id"`
RequestID string `json:"request_id"`
FunctionARN string `json:"function_arn"`
TraceID string `json:"trace_id,omitempty"`
DeadlineMs int64 `json:"deadline_ms"`
Payload []byte `json:"payload"`
}
// Response is the outcome of a Request. FunctionError is empty on success.
type Response struct {
GrantID string `json:"grant_id"`
AttemptID string `json:"attempt_id"`
RequestID string `json:"request_id"`
Payload []byte `json:"payload"`
FunctionError string `json:"function_error,omitempty"`
}
// Link carries invocations from the host to the guest and results back.
type Link interface {
// Next blocks for the next request, or returns ErrShutdown.
Next(ctx context.Context) (Request, error)
// Complete reports the outcome of a request.
Complete(ctx context.Context, resp Response) error
}
+42
View File
@@ -0,0 +1,42 @@
package hostlink
import (
"errors"
"fmt"
"io/fs"
"os"
"golang.org/x/sys/unix"
)
// FwCfgRoot is the sysfs tree the qemu_fw_cfg module exposes; removing the
// module removes every entry under it, including the guest key.
const FwCfgRoot = "/sys/firmware/qemu_fw_cfg"
// UnloadFwCfg removes the qemu_fw_cfg module from the running kernel.
func UnloadFwCfg() error {
if err := unix.DeleteModule("qemu_fw_cfg", 0); err != nil {
return fmt.Errorf("hostlink: unload qemu_fw_cfg: %w", err)
}
return nil
}
// SealFwCfg makes fw_cfg unreadable for the rest of the boot. It succeeds only
// once root is gone, so a built-in or busy module fails closed rather than
// leaving the guest key readable by code started later.
func SealFwCfg(root string, unload func() error) error {
if _, err := os.Stat(root); errors.Is(err, fs.ErrNotExist) {
return nil
} else if err != nil {
return fmt.Errorf("hostlink: fw_cfg: %w", err)
}
unloadErr := unload()
_, err := os.Stat(root)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if unloadErr == nil {
unloadErr = errors.New("unload reported success")
}
return fmt.Errorf("hostlink: fw_cfg still readable at %s: %w", root, errors.Join(unloadErr, err))
}
@@ -0,0 +1,43 @@
package hostlink
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func fwCfgTree(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "qemu_fw_cfg")
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
return root
}
func TestSealFwCfg(t *testing.T) {
t.Run("already absent needs no unload", func(t *testing.T) {
called := false
require.NoError(t, SealFwCfg(filepath.Join(t.TempDir(), "absent"), func() error { called = true; return nil }))
assert.False(t, called)
})
t.Run("unload removes the tree", func(t *testing.T) {
root := fwCfgTree(t)
require.NoError(t, SealFwCfg(root, func() error { return os.RemoveAll(root) }))
})
t.Run("unload fails and the tree remains", func(t *testing.T) {
builtIn := errors.New("module is built in")
err := SealFwCfg(fwCfgTree(t), func() error { return builtIn })
assert.ErrorIs(t, err, builtIn)
assert.ErrorContains(t, err, "still readable")
})
t.Run("unload succeeds but the tree remains", func(t *testing.T) {
assert.ErrorContains(t, SealFwCfg(fwCfgTree(t), func() error { return nil }), "unload reported success")
})
t.Run("unload errors after removing the tree", func(t *testing.T) {
root := fwCfgTree(t)
require.NoError(t, SealFwCfg(root, func() error { _ = os.RemoveAll(root); return errors.New("late") }))
})
}
@@ -0,0 +1,163 @@
//go:build lambdatestlink
package hostlink
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"sync"
)
// maxFrame bounds one frame: a 6 MiB payload as base64 plus headroom.
const maxFrame = (6<<20+2)/3*4 + 64<<10
const (
typeInvoke = "invoke"
typeShutdown = "shutdown"
typeResult = "result"
)
// ErrFrameTooLarge is returned for a frame beyond the size bound.
var ErrFrameTooLarge = errors.New("hostlink: frame too large")
type frame struct {
Type string `json:"type"`
Request *Request `json:"request,omitempty"`
Response *Response `json:"response,omitempty"`
}
// TestStream is a test-only carrier over an io.ReadWriter, framing JSON with
// a 4-byte big-endian length. It is not the guest protocol.
type TestStream struct {
rw io.ReadWriter
in chan frame
wmu sync.Mutex
err error // set before in is closed
}
var _ Link = (*TestStream)(nil)
// NewTestStream starts reading frames from rw.
func NewTestStream(rw io.ReadWriter) *TestStream {
s := &TestStream{rw: rw, in: make(chan frame)}
go s.readLoop()
return s
}
func (s *TestStream) readLoop() {
defer close(s.in)
for {
f, err := readFrame(s.rw)
if err != nil {
s.err = err
return
}
s.in <- f
}
}
func (s *TestStream) recv(ctx context.Context) (frame, error) {
select {
case f, ok := <-s.in:
if !ok {
return frame{}, s.err
}
return f, nil
case <-ctx.Done():
return frame{}, ctx.Err()
}
}
// Next implements Link.
func (s *TestStream) Next(ctx context.Context) (Request, error) {
f, err := s.recv(ctx)
if err != nil {
return Request{}, err
}
switch f.Type {
case typeShutdown:
return Request{}, ErrShutdown
case typeInvoke:
if f.Request == nil {
return Request{}, errors.New("hostlink: invoke frame without request")
}
return *f.Request, nil
default:
return Request{}, fmt.Errorf("hostlink: unexpected frame type %q", f.Type)
}
}
// Complete implements Link.
func (s *TestStream) Complete(ctx context.Context, resp Response) error {
if err := ctx.Err(); err != nil {
return err
}
return s.write(frame{Type: typeResult, Response: &resp})
}
// HostSend writes an invoke frame, as the host side of the carrier.
func (s *TestStream) HostSend(req Request) error {
return s.write(frame{Type: typeInvoke, Request: &req})
}
// HostShutdown writes a shutdown frame, as the host side of the carrier.
func (s *TestStream) HostShutdown() error {
return s.write(frame{Type: typeShutdown})
}
// HostResult reads a result frame, as the host side of the carrier.
func (s *TestStream) HostResult(ctx context.Context) (Response, error) {
f, err := s.recv(ctx)
if err != nil {
return Response{}, err
}
if f.Type != typeResult || f.Response == nil {
return Response{}, fmt.Errorf("hostlink: unexpected frame type %q", f.Type)
}
return *f.Response, nil
}
func (s *TestStream) write(f frame) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
if len(b) > maxFrame {
return ErrFrameTooLarge
}
buf := make([]byte, 4+len(b))
binary.BigEndian.PutUint32(buf, uint32(len(b)))
copy(buf[4:], b)
s.wmu.Lock()
defer s.wmu.Unlock()
_, err = s.rw.Write(buf)
return err
}
func readFrame(r io.Reader) (frame, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return frame{}, err
}
n := binary.BigEndian.Uint32(hdr[:])
if n > maxFrame {
return frame{}, ErrFrameTooLarge
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return frame{}, err
}
var f frame
if err := json.Unmarshal(body, &f); err != nil {
return frame{}, fmt.Errorf("hostlink: bad frame: %w", err)
}
return f, nil
}
@@ -0,0 +1,73 @@
//go:build lambdatestlink
package hostlink
import (
"context"
"encoding/binary"
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// pair returns the guest carrier and the raw host end of a pipe.
func pair(t *testing.T) (*TestStream, net.Conn) {
t.Helper()
g, h := net.Pipe()
t.Cleanup(func() { g.Close(); h.Close() })
return NewTestStream(g), h
}
func TestRoundTrip(t *testing.T) {
guest, host := pair(t)
hostEnd := NewTestStream(host)
req := Request{GrantID: "g", AttemptID: "a", RequestID: "r", FunctionARN: "arn", TraceID: "t", DeadlineMs: 42, Payload: []byte{0, 1, 2, 255}}
go func() { _ = hostEnd.HostSend(req) }()
got, err := guest.Next(context.Background())
require.NoError(t, err)
assert.Equal(t, req, got)
resp := Response{GrantID: "g", AttemptID: "a", RequestID: "r", Payload: []byte("out"), FunctionError: "Unhandled"}
go func() { _ = guest.Complete(context.Background(), resp) }()
gotResp, err := hostEnd.HostResult(context.Background())
require.NoError(t, err)
assert.Equal(t, resp, gotResp)
}
func TestOversizeFrameRefused(t *testing.T) {
guest, host := pair(t)
go func() {
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], maxFrame+1)
_, _ = host.Write(hdr[:])
}()
_, err := guest.Next(context.Background())
require.ErrorIs(t, err, ErrFrameTooLarge)
err = guest.Complete(context.Background(), Response{Payload: make([]byte, maxFrame)})
require.ErrorIs(t, err, ErrFrameTooLarge)
}
func TestTruncatedFrameErrors(t *testing.T) {
guest, host := pair(t)
go func() {
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], 100)
_, _ = host.Write(append(hdr[:], []byte(`{"type"`)...))
host.Close()
}()
_, err := guest.Next(context.Background())
require.Error(t, err)
assert.NotErrorIs(t, err, ErrShutdown)
}
func TestShutdownSurfaced(t *testing.T) {
guest, host := pair(t)
hostEnd := NewTestStream(host)
go func() { _ = hostEnd.HostShutdown() }()
_, err := guest.Next(context.Background())
require.ErrorIs(t, err, ErrShutdown)
}
+115
View File
@@ -0,0 +1,115 @@
package hostlink
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/pki"
)
// FwCfgDir is where the guest kernel exposes the executor's fw_cfg entries.
const FwCfgDir = FwCfgRoot + "/by_name/" + lambdav1.FwCfgPrefix
// Identity is the guest's material for one environment generation.
type Identity struct {
Cert tls.Certificate
HostCert *x509.Certificate
Scope lambdav1.Scope
HostAddr string
}
// LoadIdentity reads the executor's fw_cfg entries from dir.
func LoadIdentity(dir string) (Identity, error) {
read := func(name string) ([]byte, error) {
b, err := os.ReadFile(filepath.Join(dir, name, "raw"))
if err != nil {
return nil, fmt.Errorf("hostlink: read %s: %w", name, err)
}
return b, nil
}
var id Identity
keyPEM, err := read(lambdav1.EntryGuestKey)
if err != nil {
return id, err
}
certPEM, err := read(lambdav1.EntryGuestCert)
if err != nil {
return id, err
}
hostPEM, err := read(lambdav1.EntryHostCert)
if err != nil {
return id, err
}
scope, err := read(lambdav1.EntryScope)
if err != nil {
return id, err
}
addr, err := read(lambdav1.EntryHostAddr)
if err != nil {
return id, err
}
if id.Cert, err = tls.X509KeyPair(certPEM, keyPEM); err != nil {
return Identity{}, fmt.Errorf("hostlink: guest key pair: %w", err)
}
blk, rest := pem.Decode(hostPEM)
if blk == nil || blk.Type != "CERTIFICATE" || len(strings.TrimSpace(string(rest))) != 0 {
return Identity{}, errors.New("hostlink: host certificate: want exactly one PEM certificate")
}
if id.HostCert, err = x509.ParseCertificate(blk.Bytes); err != nil {
return Identity{}, fmt.Errorf("hostlink: host certificate: %w", err)
}
if id.Scope, err = lambdav1.ParseScope(strings.TrimSpace(string(scope))); err != nil {
return Identity{}, err
}
id.HostAddr = strings.TrimSpace(string(addr))
if _, _, err := net.SplitHostPort(id.HostAddr); err != nil {
return Identity{}, fmt.Errorf("hostlink: host address: %w", err)
}
return id, nil
}
// ClientConfig is the guest's TLS 1.3 configuration. The host has no DNS name,
// so VerifyConnection checks the pinned host certificate and its URI SAN.
func (id Identity) ClientConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{id.Cert},
NextProtos: []string{lambdav1.ALPNv1},
InsecureSkipVerify: true, //nolint:gosec // VerifyConnection performs full validation against the pinned host certificate.
VerifyConnection: func(cs tls.ConnectionState) error {
if cs.NegotiatedProtocol != lambdav1.ALPNv1 {
return fmt.Errorf("hostlink: application protocol %q, want %q", cs.NegotiatedProtocol, lambdav1.ALPNv1)
}
return pki.VerifyPinned(cs.PeerCertificates, id.HostCert, x509.ExtKeyUsageServerAuth, id.Scope.URI(lambdav1.RoleHost))
},
}
}
// Dial opens the authenticated control connection to the host. Under TLS 1.3
// the host may still refuse the guest's certificate after Dial returns; that
// surfaces on the first read, so the caller must not treat Dial as acceptance.
func Dial(ctx context.Context, id Identity) (*tls.Conn, error) {
d := tls.Dialer{NetDialer: &net.Dialer{Timeout: 10 * time.Second}, Config: id.ClientConfig()}
c, err := d.DialContext(ctx, "tcp", id.HostAddr)
if err != nil {
return nil, fmt.Errorf("hostlink: dial host %s: %w", id.HostAddr, err)
}
tc, ok := c.(*tls.Conn)
if !ok {
_ = c.Close()
return nil, fmt.Errorf("hostlink: dial host %s: unexpected connection type %T", id.HostAddr, c)
}
return tc, nil
}
+291
View File
@@ -0,0 +1,291 @@
package hostlink
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"net/url"
"os"
"path/filepath"
"testing"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/pki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
gen1 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
gen2 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-2"
)
type testIdent struct {
cert tls.Certificate
leaf *x509.Certificate
certPEM []byte
keyPEM []byte
}
func newTestIdent(t *testing.T, scope lambdav1.Scope, role string) testIdent {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
u, err := url.Parse(scope.URI(role))
require.NoError(t, err)
usage := x509.ExtKeyUsageServerAuth
if role == lambdav1.RoleGuest {
usage = x509.ExtKeyUsageClientAuth
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: role},
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage},
BasicConstraintsValid: true, URIs: []*url.URL{u},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
require.NoError(t, err)
kder, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kder})
c, err := tls.X509KeyPair(certPEM, keyPEM)
require.NoError(t, err)
leaf, err := x509.ParseCertificate(der)
require.NoError(t, err)
return testIdent{cert: c, leaf: leaf, certPEM: certPEM, keyPEM: keyPEM}
}
// hostConfig is the host end as the executor will run it: it pins one guest
// certificate and checks its URI SAN with the shared pki.VerifyPinned.
func hostConfig(host testIdent, pinGuest *x509.Certificate, scope lambdav1.Scope) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{host.cert},
ClientAuth: tls.RequireAnyClientCert, NextProtos: []string{lambdav1.ALPNv1},
VerifyConnection: func(cs tls.ConnectionState) error {
return pki.VerifyPinned(cs.PeerCertificates, pinGuest, x509.ExtKeyUsageClientAuth, scope.URI(lambdav1.RoleGuest))
},
}
}
// serve accepts one connection with cfg, writes "ok" if the handshake
// succeeds, and returns the address and the host's handshake result.
func serve(t *testing.T, cfg *tls.Config) (string, <-chan error) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
done := make(chan error, 1)
go func() {
raw, err := ln.Accept()
if err != nil {
done <- err
return
}
defer raw.Close()
tc := tls.Server(raw, cfg)
_ = tc.SetDeadline(time.Now().Add(5 * time.Second))
err = tc.Handshake()
if err == nil {
_, err = tc.Write([]byte("ok"))
}
done <- err
}()
return ln.Addr().String(), done
}
func guestIdentity(guest, pinHost testIdent, scope lambdav1.Scope, addr string) Identity {
return Identity{Cert: guest.cert, HostCert: pinHost.leaf, Scope: scope, HostAddr: addr}
}
func dialAndRead(t *testing.T, id Identity) (string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, err := Dial(ctx, id)
if err != nil {
return "", err
}
defer c.Close()
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 2)
n, err := c.Read(buf)
return string(buf[:n]), err
}
func TestDialMutualAuthentication(t *testing.T) {
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen1, lambdav1.RoleGuest)
addr, hostErr := serve(t, hostConfig(host, guest.leaf, gen1))
got, err := dialAndRead(t, guestIdentity(guest, host, gen1, addr))
require.NoError(t, err)
assert.Equal(t, "ok", got)
require.NoError(t, <-hostErr)
}
func TestDialRefusesHost(t *testing.T) {
guest := newTestIdent(t, gen1, lambdav1.RoleGuest)
pinned := newTestIdent(t, gen1, lambdav1.RoleHost)
cases := []struct {
name string
host testIdent
pin testIdent
mutate func(*tls.Config)
wantErr string
}{
{"unpinned host certificate", newTestIdent(t, gen1, lambdav1.RoleHost), pinned, nil, "peer certificate"},
{"pinned certificate of a later generation", newTestIdent(t, gen2, lambdav1.RoleHost), testIdent{}, nil, "peer identity mismatch"},
{"guest role presented as host", newTestIdent(t, gen1, lambdav1.RoleGuest), testIdent{}, nil, "peer certificate"},
{"no application protocol", pinned, pinned, func(c *tls.Config) { c.NextProtos = nil }, "application protocol"},
{"TLS 1.2 only", pinned, pinned, func(c *tls.Config) { c.MinVersion, c.MaxVersion = tls.VersionTLS12, tls.VersionTLS12 }, "protocol version"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pin := tc.pin
if pin.leaf == nil {
pin = tc.host // the guest pins exactly what is presented
}
cfg := hostConfig(tc.host, guest.leaf, gen1)
if tc.mutate != nil {
tc.mutate(cfg)
}
addr, _ := serve(t, cfg)
_, err := dialAndRead(t, guestIdentity(guest, pin, gen1, addr))
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
})
}
}
// newCASignedHost returns a CA and a host leaf it signed carrying the same URI
// SAN as a legitimate host certificate.
func newCASignedHost(t *testing.T, scope lambdav1.Scope) (ca testIdent, leaf tls.Certificate) {
t.Helper()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
u, err := url.Parse(scope.URI(lambdav1.RoleHost))
require.NoError(t, err)
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "ca"},
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true, IsCA: true, URIs: []*url.URL{u},
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
require.NoError(t, err)
caCert, err := x509.ParseCertificate(caDER)
require.NoError(t, err)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "host"},
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
URIs: []*url.URL{u},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey)
require.NoError(t, err)
return testIdent{leaf: caCert}, tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// Trusting the pin as a root is not enough: a different leaf it signed, with
// the right URI SAN, must still be refused.
func TestDialRefusesLeafSignedByPin(t *testing.T) {
guest := newTestIdent(t, gen1, lambdav1.RoleGuest)
ca, leaf := newCASignedHost(t, gen1)
for name, chain := range map[string][][]byte{
"leaf only": leaf.Certificate,
"leaf with CA": {leaf.Certificate[0], ca.leaf.Raw},
} {
t.Run(name, func(t *testing.T) {
cfg := hostConfig(testIdent{}, guest.leaf, gen1)
cfg.Certificates = []tls.Certificate{{Certificate: chain, PrivateKey: leaf.PrivateKey}}
addr, _ := serve(t, cfg)
_, err := dialAndRead(t, guestIdentity(guest, ca, gen1, addr))
require.Error(t, err)
assert.Regexp(t, "not the pinned certificate|want exactly the pinned one", err.Error())
})
}
}
func TestHostRefusalSurfacesOnFirstRead(t *testing.T) {
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen1, lambdav1.RoleGuest)
other := newTestIdent(t, gen1, lambdav1.RoleGuest)
addr, hostErr := serve(t, hostConfig(host, other.leaf, gen1))
_, err := dialAndRead(t, guestIdentity(guest, host, gen1, addr))
require.Error(t, err)
assert.Contains(t, err.Error(), "bad certificate")
assert.ErrorContains(t, <-hostErr, "peer certificate")
}
func TestHostRefusesGuestOfAnotherGeneration(t *testing.T) {
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen2, lambdav1.RoleGuest)
addr, hostErr := serve(t, hostConfig(host, guest.leaf, gen1))
_, err := dialAndRead(t, guestIdentity(guest, host, gen1, addr))
require.Error(t, err)
assert.ErrorIs(t, <-hostErr, pki.ErrPeerIdentity)
}
func writeEntries(t *testing.T, entries map[string][]byte) string {
t.Helper()
dir := t.TempDir()
for name, b := range entries {
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, name, "raw"), b, 0o600))
}
return dir
}
func validEntries(t *testing.T) map[string][]byte {
host, guest := newTestIdent(t, gen1, lambdav1.RoleHost), newTestIdent(t, gen1, lambdav1.RoleGuest)
return map[string][]byte{
lambdav1.EntryGuestKey: guest.keyPEM, lambdav1.EntryGuestCert: guest.certPEM, lambdav1.EntryHostCert: host.certPEM,
lambdav1.EntryScope: []byte(string(gen1) + "\n"), lambdav1.EntryHostAddr: []byte("10.15.8.1:18443\n"),
}
}
func TestLoadIdentity(t *testing.T) {
id, err := LoadIdentity(writeEntries(t, validEntries(t)))
require.NoError(t, err)
assert.Equal(t, gen1, id.Scope)
assert.Equal(t, "10.15.8.1:18443", id.HostAddr)
require.Len(t, id.HostCert.URIs, 1)
assert.Equal(t, gen1.URI(lambdav1.RoleHost), id.HostCert.URIs[0].String())
}
func TestLoadIdentityRejects(t *testing.T) {
other := newTestIdent(t, gen1, lambdav1.RoleGuest)
cases := []struct {
name string
edit func(map[string][]byte)
wantErr string
}{
{"missing entry", func(e map[string][]byte) { delete(e, lambdav1.EntryHostCert) }, "read host-cert"},
{"key does not match certificate", func(e map[string][]byte) { e[lambdav1.EntryGuestKey] = other.keyPEM }, "guest key pair"},
{"two host certificates", func(e map[string][]byte) {
e[lambdav1.EntryHostCert] = append(e[lambdav1.EntryHostCert], e[lambdav1.EntryHostCert]...)
}, "exactly one PEM"},
{"host certificate not PEM", func(e map[string][]byte) { e[lambdav1.EntryHostCert] = []byte("nope") }, "exactly one PEM"},
{"short scope", func(e map[string][]byte) { e[lambdav1.EntryScope] = []byte("ap-southeast-2/000000000001/fn-1") }, "want 5 segments"},
{"address without port", func(e map[string][]byte) { e[lambdav1.EntryHostAddr] = []byte("10.15.8.1") }, "host address"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := validEntries(t)
tc.edit(e)
_, err := LoadIdentity(writeEntries(t, e))
assert.ErrorContains(t, err, tc.wantErr)
})
}
}
+285
View File
@@ -0,0 +1,285 @@
// Package runtimeapi serves the AWS Lambda Runtime API (version 2018-06-01)
// inside the guest, handing one invocation at a time to the function runtime.
package runtimeapi
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"sync"
"time"
)
// MaxResponseBytes is the largest response payload a runtime may post.
const MaxResponseBytes = 6291456
const (
// ErrTypeResponseTooLarge is the function error for an oversize response.
ErrTypeResponseTooLarge = "Function.ResponseSizeTooLarge"
// ErrTypeUnhandled is the function error when the runtime names no type.
ErrTypeUnhandled = "Unhandled"
errTypeInvalidRequestID = "InvalidRequestID"
errTypeTooLarge = "RequestEntityTooLarge"
errTypeInvalidRequest = "InvalidRequest"
headerRequestID = "Lambda-Runtime-Aws-Request-Id"
headerDeadline = "Lambda-Runtime-Deadline-Ms"
headerARN = "Lambda-Runtime-Invoked-Function-Arn"
headerTraceID = "Lambda-Runtime-Trace-Id"
headerErrorType = "Lambda-Runtime-Function-Error-Type"
maxInitErrorBytes = 1 << 20
)
var (
// ErrBusy is returned by Invoke while another invocation is in flight.
ErrBusy = errors.New("runtimeapi: an invocation is already in flight")
// ErrDeadlineExceeded is returned by Invoke when the deadline passes.
ErrDeadlineExceeded = errors.New("runtimeapi: invocation deadline exceeded")
// ErrInvalidInvocation is returned by Invoke for an invocation with no request ID.
ErrInvalidInvocation = errors.New("runtimeapi: invocation has no request id")
)
// Invocation is one event handed to the runtime.
type Invocation struct {
RequestID string
FunctionARN string
Deadline time.Time
TraceID string
Payload []byte
}
// Result is what the runtime answered. FunctionError is empty on success and
// otherwise the error type.
type Result struct {
Payload []byte
FunctionError string
}
// InitError is an initialisation failure reported by the runtime.
type InitError struct {
Type string
Body []byte
}
type flight struct {
inv Invocation
taken bool
done chan Result
}
// Server implements the Runtime API for a single in-flight invocation.
type Server struct {
mu sync.Mutex
cur *flight
wake chan struct{}
initOnce sync.Once
initDone chan struct{}
initErr InitError
}
// New returns a Server with no invocation in flight.
func New() *Server {
return &Server{wake: make(chan struct{}, 1), initDone: make(chan struct{})}
}
// InitFailed is closed once the runtime reports an init error.
func (s *Server) InitFailed() <-chan struct{} { return s.initDone }
// InitError returns the reported init error, if any.
func (s *Server) InitError() (InitError, bool) {
select {
case <-s.initDone:
return s.initErr, true
default:
return InitError{}, false
}
}
// Invoke hands inv to the runtime and blocks until the runtime posts a
// response or error, the deadline passes, or ctx ends.
func (s *Server) Invoke(ctx context.Context, inv Invocation) (Result, error) {
if inv.RequestID == "" {
return Result{}, ErrInvalidInvocation
}
f := &flight{inv: inv, done: make(chan Result, 1)}
s.mu.Lock()
if s.cur != nil {
s.mu.Unlock()
return Result{}, ErrBusy
}
s.cur = f
s.mu.Unlock()
select {
case s.wake <- struct{}{}:
default:
}
timer := time.NewTimer(time.Until(inv.Deadline))
defer timer.Stop()
var cause error
select {
case res := <-f.done:
return res, nil
case <-timer.C:
cause = ErrDeadlineExceeded
case <-ctx.Done():
cause = ctx.Err()
}
s.mu.Lock()
if s.cur == f {
s.cur = nil
s.mu.Unlock()
return Result{}, cause
}
s.mu.Unlock()
// Completed while we were giving up; the result is already queued.
return <-f.done, nil
}
// Handler returns the Runtime API routes.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /2018-06-01/runtime/invocation/next", s.next)
mux.HandleFunc("POST /2018-06-01/runtime/invocation/{id}/response", s.response)
mux.HandleFunc("POST /2018-06-01/runtime/invocation/{id}/error", s.invocationError)
mux.HandleFunc("POST /2018-06-01/runtime/init/error", s.initError)
return mux
}
func (s *Server) next(w http.ResponseWriter, r *http.Request) {
for {
s.mu.Lock()
f := s.cur
if f != nil && !f.taken {
f.taken = true
s.mu.Unlock()
h := w.Header()
h.Set(headerRequestID, f.inv.RequestID)
h.Set(headerDeadline, strconv.FormatInt(f.inv.Deadline.UnixMilli(), 10))
h.Set(headerARN, f.inv.FunctionARN)
if f.inv.TraceID != "" {
h.Set(headerTraceID, f.inv.TraceID)
}
_, _ = w.Write(f.inv.Payload)
return
}
s.mu.Unlock()
select {
case <-s.wake:
case <-r.Context().Done():
return
}
}
}
// claimable reports whether id names the delivered, uncompleted invocation.
func (s *Server) claimable(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.cur != nil && s.cur.taken && s.cur.inv.RequestID == id
}
// complete finishes the invocation named id, reporting whether it was live.
func (s *Server) complete(id string, res Result) bool {
s.mu.Lock()
defer s.mu.Unlock()
f := s.cur
if f == nil || !f.taken || f.inv.RequestID != id {
return false
}
s.cur = nil
f.done <- res
return true
}
func (s *Server) response(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.claimable(id) {
invalidID(w, id)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, MaxResponseBytes+1))
if err != nil {
writeError(w, http.StatusBadRequest, errTypeInvalidRequest, "read body: "+err.Error())
return
}
if len(body) > MaxResponseBytes {
msg := "Response payload size exceeded maximum allowed payload size (" + strconv.Itoa(MaxResponseBytes) + " bytes)."
s.complete(id, Result{Payload: ErrorBody(msg, ErrTypeResponseTooLarge), FunctionError: ErrTypeResponseTooLarge})
writeError(w, http.StatusRequestEntityTooLarge, errTypeTooLarge, msg)
return
}
if !s.complete(id, Result{Payload: body}) {
invalidID(w, id)
return
}
writeStatus(w)
}
func (s *Server) invocationError(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.claimable(id) {
invalidID(w, id)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, MaxResponseBytes))
if err != nil {
writeError(w, http.StatusBadRequest, errTypeInvalidRequest, "read body: "+err.Error())
return
}
errType := r.Header.Get(headerErrorType)
if errType == "" {
errType = ErrTypeUnhandled
}
if !s.complete(id, Result{Payload: body, FunctionError: errType}) {
invalidID(w, id)
return
}
writeStatus(w)
}
func (s *Server) initError(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, maxInitErrorBytes))
s.initOnce.Do(func() {
s.initErr = InitError{Type: r.Header.Get(headerErrorType), Body: body}
close(s.initDone)
})
writeStatus(w)
}
func invalidID(w http.ResponseWriter, id string) {
writeError(w, http.StatusBadRequest, errTypeInvalidRequestID, "Invalid request ID: "+id)
}
func writeStatus(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"status":"OK"}`))
}
func writeError(w http.ResponseWriter, code int, errType, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write(ErrorBody(msg, errType))
}
type errorPayload struct {
ErrorMessage string `json:"errorMessage"`
ErrorType string `json:"errorType"`
}
// ErrorBody renders the Lambda error document for msg and errType.
func ErrorBody(msg, errType string) []byte {
b, err := json.Marshal(errorPayload{ErrorMessage: msg, ErrorType: errType})
if err != nil {
return []byte(`{"errorType":"Runtime.Unknown"}`)
}
return b
}
@@ -0,0 +1,292 @@
package runtimeapi
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const base = "/2018-06-01/runtime/"
type invokeOut struct {
res Result
err error
}
func start(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
s := New()
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return s, ts
}
func invokeAsync(s *Server, inv Invocation) <-chan invokeOut {
ch := make(chan invokeOut, 1)
go func() {
res, err := s.Invoke(context.Background(), inv)
ch <- invokeOut{res, err}
}()
return ch
}
func post(t *testing.T, url string, body []byte, hdr map[string]string) (*http.Response, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
require.NoError(t, err)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, b
}
func get(t *testing.T, url string) (*http.Response, []byte) {
t.Helper()
resp, err := http.Get(url)
require.NoError(t, err)
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, b
}
func inv(id string, d time.Duration) Invocation {
return Invocation{
RequestID: id,
FunctionARN: "arn:aws:lambda:ap-southeast-2:000000000001:function:f",
Deadline: time.Now().Add(d),
Payload: []byte(`{"a":1}`),
}
}
func errType(t *testing.T, body []byte) string {
t.Helper()
var e map[string]string
require.NoError(t, json.Unmarshal(body, &e))
assert.NotEmpty(t, e["errorMessage"])
return e["errorType"]
}
func TestNextResponseRoundTrip(t *testing.T) {
s, ts := start(t)
in := inv("req-1", 2*time.Second)
in.TraceID = "Root=1-abc"
out := invokeAsync(s, in)
resp, body := get(t, ts.URL+base+"invocation/next")
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, `{"a":1}`, string(body))
assert.Equal(t, "req-1", resp.Header.Get("Lambda-Runtime-Aws-Request-Id"))
assert.Equal(t, strconv.FormatInt(in.Deadline.UnixMilli(), 10), resp.Header.Get("Lambda-Runtime-Deadline-Ms"))
assert.Equal(t, in.FunctionARN, resp.Header.Get("Lambda-Runtime-Invoked-Function-Arn"))
assert.Equal(t, "Root=1-abc", resp.Header.Get("Lambda-Runtime-Trace-Id"))
r, _ := post(t, ts.URL+base+"invocation/req-1/response", []byte(`{"sum":3}`), nil)
assert.Equal(t, http.StatusAccepted, r.StatusCode)
got := <-out
require.NoError(t, got.err)
assert.Equal(t, `{"sum":3}`, string(got.res.Payload))
assert.Empty(t, got.res.FunctionError)
}
func TestTraceHeaderOmittedWhenUnset(t *testing.T) {
s, ts := start(t)
invokeAsync(s, inv("req-1", time.Second))
resp, _ := get(t, ts.URL+base+"invocation/next")
_, present := resp.Header["Lambda-Runtime-Trace-Id"]
assert.False(t, present)
}
func TestErrorPostKeepsBodyAndType(t *testing.T) {
s, ts := start(t)
out := invokeAsync(s, inv("req-1", 2*time.Second))
get(t, ts.URL+base+"invocation/next")
body := []byte(`{"errorMessage":"boom","errorType":"Custom"}`)
r, _ := post(t, ts.URL+base+"invocation/req-1/error", body, map[string]string{"Lambda-Runtime-Function-Error-Type": "Custom"})
assert.Equal(t, http.StatusAccepted, r.StatusCode)
got := <-out
require.NoError(t, got.err)
assert.Equal(t, "Custom", got.res.FunctionError)
assert.JSONEq(t, string(body), string(got.res.Payload))
}
func TestErrorPostWithoutTypeIsUnhandled(t *testing.T) {
s, ts := start(t)
out := invokeAsync(s, inv("req-1", 2*time.Second))
get(t, ts.URL+base+"invocation/next")
post(t, ts.URL+base+"invocation/req-1/error", []byte(`{}`), nil)
assert.Equal(t, "Unhandled", (<-out).res.FunctionError)
}
func TestUnknownAndRepeatedIDRefused(t *testing.T) {
s, ts := start(t)
r, b := post(t, ts.URL+base+"invocation/nope/response", []byte("x"), nil)
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, "InvalidRequestID", errType(t, b))
out := invokeAsync(s, inv("req-1", 2*time.Second))
get(t, ts.URL+base+"invocation/next")
post(t, ts.URL+base+"invocation/req-1/response", []byte("ok"), nil)
<-out
r, b = post(t, ts.URL+base+"invocation/req-1/response", []byte("again"), nil)
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, "InvalidRequestID", errType(t, b))
r, _ = post(t, ts.URL+base+"invocation/req-1/error", []byte("{}"), nil)
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
}
func TestResponseNotAcceptedBeforeDelivery(t *testing.T) {
s, ts := start(t)
invokeAsync(s, inv("req-1", time.Second))
r, _ := post(t, ts.URL+base+"invocation/req-1/response", []byte("early"), nil)
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
}
func TestOversizeResponse(t *testing.T) {
s, ts := start(t)
out := invokeAsync(s, inv("req-1", 5*time.Second))
get(t, ts.URL+base+"invocation/next")
r, b := post(t, ts.URL+base+"invocation/req-1/response", make([]byte, MaxResponseBytes+1), nil)
assert.Equal(t, http.StatusRequestEntityTooLarge, r.StatusCode)
assert.Equal(t, "RequestEntityTooLarge", errType(t, b))
got := <-out
require.NoError(t, got.err)
assert.Equal(t, "Function.ResponseSizeTooLarge", got.res.FunctionError)
assert.Equal(t, "Function.ResponseSizeTooLarge", errType(t, got.res.Payload))
}
func TestResponseAtLimitAccepted(t *testing.T) {
s, ts := start(t)
out := invokeAsync(s, inv("req-1", 5*time.Second))
get(t, ts.URL+base+"invocation/next")
r, _ := post(t, ts.URL+base+"invocation/req-1/response", make([]byte, MaxResponseBytes), nil)
assert.Equal(t, http.StatusAccepted, r.StatusCode)
got := <-out
require.NoError(t, got.err)
assert.Len(t, got.res.Payload, MaxResponseBytes)
}
func TestDeadlineExpiryRefusesLateResponse(t *testing.T) {
s, ts := start(t)
out := invokeAsync(s, inv("req-1", 40*time.Millisecond))
get(t, ts.URL+base+"invocation/next")
got := <-out
require.ErrorIs(t, got.err, ErrDeadlineExceeded)
r, b := post(t, ts.URL+base+"invocation/req-1/response", []byte("late"), nil)
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, "InvalidRequestID", errType(t, b))
// The slot is free again for the next invocation.
out = invokeAsync(s, inv("req-2", 2*time.Second))
resp, _ := get(t, ts.URL+base+"invocation/next")
assert.Equal(t, "req-2", resp.Header.Get("Lambda-Runtime-Aws-Request-Id"))
post(t, ts.URL+base+"invocation/req-2/response", []byte("ok"), nil)
require.NoError(t, (<-out).err)
}
func TestConcurrentInvokeRefused(t *testing.T) {
s, ts := start(t)
out := invokeAsync(s, inv("req-1", 2*time.Second))
require.Eventually(t, func() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.cur != nil
}, time.Second, time.Millisecond)
_, err := s.Invoke(context.Background(), inv("req-2", time.Second))
require.ErrorIs(t, err, ErrBusy)
get(t, ts.URL+base+"invocation/next")
post(t, ts.URL+base+"invocation/req-1/response", []byte("ok"), nil)
require.NoError(t, (<-out).err)
}
func TestInvokeContextCancel(t *testing.T) {
s, _ := start(t)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := s.Invoke(ctx, inv("req-1", time.Minute))
done <- err
}()
cancel()
require.ErrorIs(t, <-done, context.Canceled)
}
func TestNextBlocksUntilInvoke(t *testing.T) {
s, ts := start(t)
type got struct {
resp *http.Response
body []byte
}
ch := make(chan got, 1)
go func() {
resp, err := http.Get(ts.URL + base + "invocation/next") //nolint:noctx // test client
if err != nil {
ch <- got{}
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
ch <- got{resp, body}
}()
select {
case <-ch:
t.Fatal("next returned before any invocation")
case <-time.After(50 * time.Millisecond):
}
out := invokeAsync(s, inv("req-1", 2*time.Second))
g := <-ch
require.NotNil(t, g.resp, "next request failed")
assert.Equal(t, "req-1", g.resp.Header.Get("Lambda-Runtime-Aws-Request-Id"))
post(t, ts.URL+base+"invocation/req-1/response", []byte("ok"), nil)
require.NoError(t, (<-out).err)
}
func TestInitErrorRecorded(t *testing.T) {
s, ts := start(t)
_, ok := s.InitError()
require.False(t, ok)
r, _ := post(t, ts.URL+base+"init/error", []byte(`{"errorMessage":"bad"}`), map[string]string{"Lambda-Runtime-Function-Error-Type": "Runtime.InitError"})
assert.Equal(t, http.StatusAccepted, r.StatusCode)
select {
case <-s.InitFailed():
default:
t.Fatal("InitFailed not closed")
}
ie, ok := s.InitError()
require.True(t, ok)
assert.Equal(t, "Runtime.InitError", ie.Type)
assert.JSONEq(t, `{"errorMessage":"bad"}`, string(ie.Body))
}
func TestInvokeRequiresRequestID(t *testing.T) {
s := New()
_, err := s.Invoke(context.Background(), Invocation{Deadline: time.Now().Add(time.Second)})
require.ErrorIs(t, err, ErrInvalidInvocation)
}
+37
View File
@@ -0,0 +1,37 @@
package pki
import (
"bytes"
"crypto/x509"
"errors"
"fmt"
)
// ErrPeerIdentity is returned when a pinned peer certificate does not carry
// the expected URI SAN.
var ErrPeerIdentity = errors.New("pki: peer identity mismatch")
// VerifyPinned requires chain to be exactly the pinned certificate, valid now
// for usage, whose only URI SAN is wantURI. Trusting the pin as a root alone
// would also accept other leaves it signed, so the DER is compared first.
func VerifyPinned(chain []*x509.Certificate, pinned *x509.Certificate, usage x509.ExtKeyUsage, wantURI string) error {
if pinned == nil {
return errors.New("pki: no pinned peer certificate")
}
if len(chain) != 1 {
return fmt.Errorf("pki: peer sent %d certificates, want exactly the pinned one", len(chain))
}
leaf := chain[0]
if !bytes.Equal(leaf.Raw, pinned.Raw) {
return errors.New("pki: peer certificate is not the pinned certificate")
}
roots := x509.NewCertPool()
roots.AddCert(pinned)
if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{usage}}); err != nil {
return fmt.Errorf("pki: peer certificate: %w", err)
}
if len(leaf.URIs) != 1 || leaf.URIs[0].String() != wantURI {
return fmt.Errorf("%w: got %v, want %s", ErrPeerIdentity, leaf.URIs, wantURI)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
package pki
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"math/big"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func pinnedCert(t *testing.T, uris ...string) *x509.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
for _, s := range uris {
u, err := url.Parse(s)
require.NoError(t, err)
tmpl.URIs = append(tmpl.URIs, u)
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
require.NoError(t, err)
c, err := x509.ParseCertificate(der)
require.NoError(t, err)
return c
}
func TestVerifyPinned(t *testing.T) {
const want = "spinifex-lambda://r/a/f/e/g/guest"
pin := pinnedCert(t, want)
other := pinnedCert(t, want)
twoURIs := pinnedCert(t, want, "spinifex-lambda://r/a/f/e/g/host")
require.NoError(t, VerifyPinned([]*x509.Certificate{pin}, pin, x509.ExtKeyUsageClientAuth, want))
cases := []struct {
name string
chain []*x509.Certificate
pin *x509.Certificate
usage x509.ExtKeyUsage
uri string
msg string
}{
{"no pin", []*x509.Certificate{pin}, nil, x509.ExtKeyUsageClientAuth, want, "no pinned peer certificate"},
{"empty chain", nil, pin, x509.ExtKeyUsageClientAuth, want, "sent 0 certificates"},
{"extra chain certificate", []*x509.Certificate{pin, other}, pin, x509.ExtKeyUsageClientAuth, want, "sent 2 certificates"},
{"same identity, other key", []*x509.Certificate{other}, pin, x509.ExtKeyUsageClientAuth, want, "not the pinned certificate"},
{"wrong usage", []*x509.Certificate{pin}, pin, x509.ExtKeyUsageServerAuth, want, "incompatible key usage"},
{"wrong URI", []*x509.Certificate{pin}, pin, x509.ExtKeyUsageClientAuth, "spinifex-lambda://r/a/f/e/g2/guest", "identity mismatch"},
{"second URI", []*x509.Certificate{twoURIs}, twoURIs, x509.ExtKeyUsageClientAuth, want, "identity mismatch"},
}
for _, tc := range cases {
err := VerifyPinned(tc.chain, tc.pin, tc.usage, tc.uri)
assert.ErrorContains(t, err, tc.msg, tc.name)
}
assert.ErrorIs(t, VerifyPinned([]*x509.Certificate{pin}, pin, x509.ExtKeyUsageClientAuth, "x://y"), ErrPeerIdentity)
}
+31 -1
View File
@@ -8,6 +8,8 @@ import (
"slices"
"sort"
"time"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
)
// AccountStatusTerminating mirrors iamdomain.AccountStatusTerminating.
@@ -29,6 +31,10 @@ type Engine struct {
// and this is the only view into it while it runs.
OnStage func(ctx context.Context, stage StageResult)
// OnQuiesced receives the recorded quiescence evaluation once the account is
// TERMINATING and before the first stage. It records evidence and gates nothing.
OnQuiesced func(ctx context.Context, quiescence operatorv1.IAMQuiescence)
// ec2Inventory is EC2's read-only platform inventory. Only DryRun consults
// it, and only to log a comparison; no teardown or Inventory call does.
ec2Inventory ec2PlatformInventory
@@ -118,9 +124,13 @@ func (e *Engine) Teardown(ctx context.Context, req Request) (*Result, error) {
return e.Inventory(ctx, req.AccountID)
}
if _, err := e.Accounts.QuiesceAccount(req.AccountID, req.OperationID, req.Fence); err != nil {
generation, err := e.Accounts.QuiesceAccount(req.AccountID, req.OperationID, req.Fence)
if err != nil {
return nil, fmt.Errorf("quiesce account: %w", err)
}
if e.OnQuiesced != nil {
e.OnQuiesced(ctx, e.evaluateQuiescence(ctx, req, generation))
}
e.logger().InfoContext(ctx, "Account teardown started",
"accountID", account.AccountID, "accountName", account.AccountName, "force", req.Force)
@@ -325,3 +335,23 @@ func containsResource(resources []Resource, want Resource) bool {
func SortReapers(reapers []Reaper) {
sort.SliceStable(reapers, func(i, j int) bool { return reapers[i].Stage() < reapers[j].Stage() })
}
// evaluateQuiescence reads back what the quiesce wrote and, only if the fence
// is confirmed, asks EC2 for its platform inventory within a short budget.
func (e *Engine) evaluateQuiescence(ctx context.Context, req Request, generation uint64) operatorv1.IAMQuiescence {
ev := QuiesceEvidence{
OperationID: req.OperationID, Fence: req.Fence, Generation: generation, EvaluatedAt: time.Now(),
}
ev.Account, ev.ReadbackErr = e.Accounts.GetAccount(req.AccountID)
if _, confirmed := fenceReadback(ev); confirmed && e.ec2Inventory != nil {
budget := ec2PlatformShadowBudget
if e.ec2PlatformShadowBudget > 0 {
budget = e.ec2PlatformShadowBudget
}
inventoryCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
reply := e.ec2Inventory.PlatformInventory(inventoryCtx, req.AccountID, req.OperationID)
ev.Inventory = &reply
}
return EvaluateQuiescence(ev)
}
@@ -61,6 +61,8 @@ func (f *fakeAccounts) QuiesceAccount(_, operationID string, fence uint64) (uint
f.statuses = append(f.statuses, AccountStatusTerminating)
f.quiesces = append(f.quiesces, quiesceCall{operationID, fence})
f.account.Status = AccountStatusTerminating
f.account.Generation = 1
f.account.TerminationOp = &TerminationOp{OperationID: operationID, Fence: fence}
return 1, nil
}
@@ -0,0 +1,164 @@
package accountlifecycle
import (
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
)
// finalInventoryRereadWired says whether anything re-reads the owner inventory
// before the account record goes. Until it does, a write landing after the
// reaper stage is orphaned, so no kind may be recorded as quiesced.
var finalInventoryRereadWired = false
// QuiesceEvidence is what the engine read after QuiesceAccount returned.
type QuiesceEvidence struct {
OperationID string
Fence uint64
Generation uint64
// Account is the IAM record read back after the quiesce.
Account *Account
ReadbackErr error
Inventory *ec2v1.PlatformInventoryReply
EvaluatedAt time.Time
}
// enforcementConsumers are the consumers with an accepted contract for the
// account admission fact. Predastore is deliberately absent: it has none.
var enforcementConsumers = []operatorv1.ConsumerBasis{
{Consumer: "awsgw-admission", Basis: "ADR-0005:S7"},
{Consumer: "ecr-registry", Basis: "ADR-0005:S7"},
{Consumer: "sts-mint-gate", Basis: "ADR-0008:S9 (IAM-owned)"},
}
// accountedWriters cannot be fenced and are covered by the owner inventory.
var accountedWriters = []operatorv1.AccountedWriter{
{Writer: "daemon-nats-handlers", Basis: "owner-inventory", Ref: "mulga-tpoz4"},
}
// ec2PlatformKinds are the kinds the first scope covers. The inventory's own
// tag-index kind belongs to a different register row and is not part of it.
var ec2PlatformKinds = []string{
ec2v1.PlatformKindKeyPair,
ec2v1.PlatformKindPlacementGroup,
ec2v1.PlatformKindLaunchTemplate,
}
type unresolvedEntry struct {
scope, class, reason string
}
// otherScopes lists every scope the first exercise does not cover, by name.
// A register row family missing from here would read as quiesced by omission.
var otherScopes = []unresolvedEntry{
{"ec2-instances", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ec2-spot-requests", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ebs-volumes", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ec2-snapshots-images", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ec2-vpc", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"host-network", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ec2-account-settings", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ec2-tag-index", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"iam", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"elbv2", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"acm", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"eks", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ecs", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"rds", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"dns", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"ochre", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"quota", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"idempotency", operatorv1.UnresolvedClassWriter, operatorv1.ReasonNoCompleteOwnerInventory},
{"s3", operatorv1.UnresolvedClassConsumer, operatorv1.ReasonNoAcceptedEnforcement},
{"ecr", operatorv1.UnresolvedClassConsumer, operatorv1.ReasonNoAcceptedEnforcement},
{"lambda", operatorv1.UnresolvedClassWriter, operatorv1.ReasonParticipantNotDeclared},
{"sqs", operatorv1.UnresolvedClassWriter, operatorv1.ReasonParticipantNotDeclared},
}
// EvaluateQuiescence applies ADR-0008:S9's three conditions to the evidence and
// records what holds. A fence the read-back does not confirm stops everything
// else; otherwise every scope is listed, and none is reported quiesced yet.
func EvaluateQuiescence(ev QuiesceEvidence) operatorv1.IAMQuiescence {
out := operatorv1.IAMQuiescence{
OperationID: ev.OperationID,
Fence: ev.Fence,
Generation: ev.Generation,
QuiescedScope: []string{},
Unresolved: []operatorv1.UnresolvedScope{},
Consumers: []operatorv1.ConsumerBasis{},
Writers: []operatorv1.AccountedWriter{},
Inventory: []operatorv1.InventoryStatus{},
EvaluatedAt: ev.EvaluatedAt.UTC().Format(time.RFC3339),
}
if reason, ok := fenceReadback(ev); !ok {
out.Unresolved = append(out.Unresolved, operatorv1.UnresolvedScope{
Scope: "all", Class: operatorv1.UnresolvedClassFence, Reason: reason,
})
return out
}
out.Consumers = append(out.Consumers, enforcementConsumers...)
out.Writers = append(out.Writers, accountedWriters...)
for _, kind := range ec2PlatformKinds {
status, complete := kindInventory(ev.Inventory, kind)
out.Inventory = append(out.Inventory, operatorv1.InventoryStatus{Kind: kind, Status: status})
switch {
case !complete:
out.Unresolved = append(out.Unresolved, writerEntry("ec2-"+kind, operatorv1.ReasonOwnerInventoryIncomplete))
case !finalInventoryRereadWired:
out.Unresolved = append(out.Unresolved, writerEntry("ec2-"+kind, operatorv1.ReasonFinalInventoryRereadNotWired))
default:
out.QuiescedScope = append(out.QuiescedScope, kind)
}
}
for _, entry := range otherScopes {
out.Unresolved = append(out.Unresolved, operatorv1.UnresolvedScope{
Scope: entry.scope, Class: entry.class, Reason: entry.reason,
})
}
return out
}
func writerEntry(scope, reason string) operatorv1.UnresolvedScope {
return operatorv1.UnresolvedScope{Scope: scope, Class: operatorv1.UnresolvedClassWriter, Reason: reason}
}
// fenceReadback is condition 1: the operation record and the IAM account record
// hold the same operation, fence and generation.
func fenceReadback(ev QuiesceEvidence) (reason string, ok bool) {
if ev.ReadbackErr != nil || ev.Account == nil {
return operatorv1.ReasonIAMReadbackFailed, false
}
op := ev.Account.TerminationOp
if ev.Account.Status != AccountStatusTerminating || op == nil ||
op.OperationID != ev.OperationID || op.Fence != ev.Fence || ev.Account.Generation != ev.Generation {
return operatorv1.ReasonFenceMismatch, false
}
return "", true
}
// kindInventory reports one kind's status and whether the owner scan was
// complete. A missing or repeated kind, or no reply at all, is incomplete.
func kindInventory(reply *ec2v1.PlatformInventoryReply, kind string) (status string, complete bool) {
if reply == nil {
return ec2v1.PlatformStatusScanIncomplete, false
}
var found *ec2v1.PlatformKindReport
for i := range reply.Kinds {
if reply.Kinds[i].Kind != kind {
continue
}
if found != nil {
return ec2v1.PlatformStatusScanIncomplete, false
}
found = &reply.Kinds[i]
}
if found == nil || found.Status == ec2v1.PlatformStatusScanIncomplete {
return ec2v1.PlatformStatusScanIncomplete, false
}
return found.Status, true
}
@@ -0,0 +1,281 @@
package accountlifecycle
//test:in-package — the evaluator, its scope table and the engine hook are
// unexported, and the table is what keeps a scope from reading as quiesced by omission.
import (
"context"
"errors"
"slices"
"testing"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func confirmedEvidence() QuiesceEvidence {
return QuiesceEvidence{
OperationID: "op-1", Fence: 2, Generation: 5,
Account: &Account{
AccountID: testAccountID, Status: AccountStatusTerminating, Generation: 5,
TerminationOp: &TerminationOp{OperationID: "op-1", Fence: 2},
},
Inventory: completeInventory(),
EvaluatedAt: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC),
}
}
func completeInventory() *ec2v1.PlatformInventoryReply {
reply := &ec2v1.PlatformInventoryReply{AccountID: testAccountID}
for _, kind := range ec2v1.PlatformKinds() {
reply.Kinds = append(reply.Kinds, ec2v1.PlatformKindReport{Kind: kind, Status: ec2v1.PlatformStatusObservedClean})
}
return reply
}
func unresolvedFor(q operatorv1.IAMQuiescence, scope string) []operatorv1.UnresolvedScope {
var out []operatorv1.UnresolvedScope
for _, entry := range q.Unresolved {
if entry.Scope == scope {
out = append(out, entry)
}
}
return out
}
// With the fence confirmed and every L09 kind scanned complete, the evidence is
// recorded but no kind is quiesced: nothing re-reads the inventory before the
// account record goes, so a late write would be orphaned (ADR-0008:S9 condition 3).
func TestEvaluateQuiescenceRecordsEvidenceButQuiescesNothingWithoutAFinalReread(t *testing.T) {
q := EvaluateQuiescence(confirmedEvidence())
assert.Equal(t, "op-1", q.OperationID)
assert.Equal(t, uint64(2), q.Fence)
assert.Equal(t, uint64(5), q.Generation)
assert.Empty(t, q.QuiescedScope)
assert.NotNil(t, q.QuiescedScope, "evaluated-and-empty must serialise as [] not null")
for _, kind := range ec2PlatformKinds {
assert.Equal(t, []operatorv1.UnresolvedScope{{
Scope: "ec2-" + kind, Class: operatorv1.UnresolvedClassWriter,
Reason: operatorv1.ReasonFinalInventoryRereadNotWired,
}}, unresolvedFor(q, "ec2-"+kind))
}
assert.ElementsMatch(t, []operatorv1.InventoryStatus{
{Kind: ec2v1.PlatformKindKeyPair, Status: ec2v1.PlatformStatusObservedClean},
{Kind: ec2v1.PlatformKindPlacementGroup, Status: ec2v1.PlatformStatusObservedClean},
{Kind: ec2v1.PlatformKindLaunchTemplate, Status: ec2v1.PlatformStatusObservedClean},
}, q.Inventory)
assert.Equal(t, []operatorv1.AccountedWriter{{Writer: "daemon-nats-handlers", Basis: "owner-inventory", Ref: "mulga-tpoz4"}}, q.Writers)
assert.Equal(t, "2026-11-02T00:00:00Z", q.EvaluatedAt)
}
// The wiring step flips this switch; the complete kinds are then, and only then, named.
func TestEvaluateQuiescenceNamesCompleteKindsOnceTheRereadIsWired(t *testing.T) {
finalInventoryRereadWired = true
t.Cleanup(func() { finalInventoryRereadWired = false })
ev := confirmedEvidence()
ev.Inventory.Kinds[1].Status = ec2v1.PlatformStatusScanIncomplete
q := EvaluateQuiescence(ev)
assert.Equal(t, []string{ec2v1.PlatformKindKeyPair, ec2v1.PlatformKindLaunchTemplate}, q.QuiescedScope)
assert.Equal(t, operatorv1.ReasonOwnerInventoryIncomplete, unresolvedFor(q, "ec2-placement-group")[0].Reason)
}
func TestEvaluateQuiescenceRecordsAnIncompleteInventoryPerKind(t *testing.T) {
cases := map[string]func(*QuiesceEvidence){
"one kind scan-incomplete": func(ev *QuiesceEvidence) { ev.Inventory.Kinds[0].Status = ec2v1.PlatformStatusScanIncomplete },
"kind missing": func(ev *QuiesceEvidence) { ev.Inventory.Kinds = ev.Inventory.Kinds[1:] },
"kind repeated": func(ev *QuiesceEvidence) {
ev.Inventory.Kinds = append(ev.Inventory.Kinds, ev.Inventory.Kinds[0])
},
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
ev := confirmedEvidence()
mutate(&ev)
q := EvaluateQuiescence(ev)
got := unresolvedFor(q, "ec2-key-pair")
require.Len(t, got, 1)
assert.Equal(t, operatorv1.ReasonOwnerInventoryIncomplete, got[0].Reason)
assert.Equal(t, operatorv1.ReasonFinalInventoryRereadNotWired, unresolvedFor(q, "ec2-launch-template")[0].Reason,
"the other kinds keep their own result")
})
}
t.Run("no reply at all", func(t *testing.T) {
ev := confirmedEvidence()
ev.Inventory = nil
q := EvaluateQuiescence(ev)
for _, kind := range ec2PlatformKinds {
assert.Equal(t, operatorv1.ReasonOwnerInventoryIncomplete, unresolvedFor(q, "ec2-"+kind)[0].Reason)
}
})
}
// Condition 1: a read-back that does not confirm the operation, fence and
// generation records nothing as quiesced and evaluates nothing else.
func TestEvaluateQuiescenceRecordsNothingWhenTheFenceIsNotConfirmed(t *testing.T) {
cases := map[string]struct {
mutate func(*QuiesceEvidence)
reason string
}{
"not terminating": {func(ev *QuiesceEvidence) { ev.Account.Status = "ACTIVE" }, operatorv1.ReasonFenceMismatch},
"no operation": {func(ev *QuiesceEvidence) { ev.Account.TerminationOp = nil }, operatorv1.ReasonFenceMismatch},
"other operation": {func(ev *QuiesceEvidence) { ev.Account.TerminationOp.OperationID = "op-2" }, operatorv1.ReasonFenceMismatch},
"higher fence in IAM": {func(ev *QuiesceEvidence) { ev.Account.TerminationOp.Fence = 3 }, operatorv1.ReasonFenceMismatch},
"other generation": {func(ev *QuiesceEvidence) { ev.Account.Generation = 6 }, operatorv1.ReasonFenceMismatch},
"read-back failed": {func(ev *QuiesceEvidence) { ev.Account, ev.ReadbackErr = nil, errors.New("kv down") }, operatorv1.ReasonIAMReadbackFailed},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
finalInventoryRereadWired = true
t.Cleanup(func() { finalInventoryRereadWired = false })
ev := confirmedEvidence()
tc.mutate(&ev)
q := EvaluateQuiescence(ev)
assert.Empty(t, q.QuiescedScope)
assert.Equal(t, []operatorv1.UnresolvedScope{{Scope: "all", Class: operatorv1.UnresolvedClassFence, Reason: tc.reason}}, q.Unresolved)
assert.Empty(t, q.Consumers)
assert.Empty(t, q.Writers)
assert.Empty(t, q.Inventory)
})
}
}
// Every scope outside the first exercise is named. The expected list is the
// lifecycle register's rows other than L01, L02 and L09, so a new row has to be
// added here and in the table rather than reading as quiesced by omission.
func TestEveryOtherScopeIsRecordedUnresolvedByName(t *testing.T) {
want := []string{
"ec2-instances", "ec2-spot-requests", "ebs-volumes", "ec2-snapshots-images", "ec2-vpc",
"host-network", "ec2-account-settings", "ec2-tag-index", "iam", "elbv2", "acm", "eks", "ecs",
"rds", "dns", "ochre", "quota", "idempotency", "s3", "ecr", "lambda", "sqs",
}
q := EvaluateQuiescence(confirmedEvidence())
for _, scope := range want {
got := unresolvedFor(q, scope)
require.Len(t, got, 1, scope)
assert.NotEmpty(t, got[0].Reason, scope)
}
assert.Len(t, q.Unresolved, len(want)+len(ec2PlatformKinds), "no scope beyond the register and the three kinds")
for _, entry := range otherScopes {
assert.False(t, slices.Contains(ec2PlatformKinds, entry.scope), entry.scope)
}
}
// Predastore has no accepted enforcement contract, so the scopes it admits writes
// for stay unresolved and it is never cited as a consumer, even once the
// re-read is wired and every L09 kind is complete.
func TestPredastoreIsNeverCitedAndItsScopesNeverQuiesce(t *testing.T) {
finalInventoryRereadWired = true
t.Cleanup(func() { finalInventoryRereadWired = false })
q := EvaluateQuiescence(confirmedEvidence())
for _, scope := range []string{"s3", "ecr"} {
got := unresolvedFor(q, scope)
require.Len(t, got, 1)
assert.Equal(t, operatorv1.UnresolvedClassConsumer, got[0].Class)
assert.Equal(t, operatorv1.ReasonNoAcceptedEnforcement, got[0].Reason)
assert.NotContains(t, q.QuiescedScope, scope)
}
for _, consumer := range q.Consumers {
assert.NotContains(t, consumer.Consumer, "predastore")
want := "ADR-0005:S7"
if consumer.Consumer == "sts-mint-gate" {
want = "ADR-0008:S9 (IAM-owned)"
}
assert.Equal(t, want, consumer.Basis)
}
assert.Equal(t, []string{"awsgw-admission", "ecr-registry", "sts-mint-gate"},
[]string{q.Consumers[0].Consumer, q.Consumers[1].Consumer, q.Consumers[2].Consumer})
}
type stubInventory struct {
reply ec2v1.PlatformInventoryReply
calls int
}
func (s *stubInventory) PlatformInventory(context.Context, string, string) ec2v1.PlatformInventoryReply {
s.calls++
return s.reply
}
// The hook fires once, after the quiesce and before the first stage, carrying the
// operation, fence and generation the quiesce returned.
func TestTeardownReportsQuiescenceAfterTheQuiesceAndBeforeTheFirstStage(t *testing.T) {
accounts := newFakeAccounts()
var events []string
reaper := &fakeReaper{kind: "volume", stage: StageStorage, resources: []Resource{{Kind: "volume", ID: "vol-1"}}}
engine := quietEngine(accounts, reaper)
inventory := &stubInventory{reply: *completeInventory()}
engine.ec2Inventory = inventory
var got operatorv1.IAMQuiescence
engine.OnQuiesced = func(_ context.Context, q operatorv1.IAMQuiescence) {
events = append(events, "quiesced")
assert.Equal(t, 0, reaper.deletesAttempted, "no stage may have run yet")
assert.Equal(t, AccountStatusTerminating, accounts.account.Status)
got = q
}
engine.OnStage = func(context.Context, StageResult) {
if len(events) == 0 || events[len(events)-1] != "stage" {
events = append(events, "stage")
}
}
req := request()
req.OperationID, req.Fence = "op-7", 3
_, err := engine.Teardown(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, []string{"quiesced", "stage"}, events)
assert.Equal(t, "op-7", got.OperationID)
assert.Equal(t, uint64(3), got.Fence)
assert.Equal(t, uint64(1), got.Generation)
assert.Equal(t, 1, inventory.calls)
assert.True(t, accounts.deleted, "recording must not change the legacy teardown")
}
// A fence the read-back does not confirm must not spend an inventory call, and a
// failed read-back must not stop the teardown: this step records and gates nothing.
func TestTeardownRecordsAFailedReadbackAndStillRunsEveryStage(t *testing.T) {
accounts := newFakeAccounts()
engine := quietEngine(accounts)
inventory := &stubInventory{reply: *completeInventory()}
engine.ec2Inventory = inventory
var got operatorv1.IAMQuiescence
engine.OnQuiesced = func(_ context.Context, q operatorv1.IAMQuiescence) { got = q }
accounts.getErr = nil
engine.Accounts = &readbackFails{fakeAccounts: accounts}
req := request()
req.OperationID, req.Fence = "op-1", 1
_, err := engine.Teardown(context.Background(), req)
require.NoError(t, err)
require.Len(t, got.Unresolved, 1)
assert.Equal(t, operatorv1.ReasonIAMReadbackFailed, got.Unresolved[0].Reason)
assert.Zero(t, inventory.calls)
assert.True(t, accounts.deleted)
}
// readbackFails serves the pre-quiesce read and fails the one after it.
type readbackFails struct {
*fakeAccounts
reads int
}
func (r *readbackFails) GetAccount(id string) (*Account, error) {
r.reads++
if r.reads > 1 {
return nil, errors.New("kv down")
}
return r.fakeAccounts.GetAccount(id)
}
@@ -106,9 +106,14 @@ func (a *IAMAccounts) GetAccount(accountID string) (*Account, error) {
if err != nil {
return nil, err
}
return &Account{
out := &Account{
AccountID: account.AccountID, AccountName: account.AccountName, Status: account.Status,
}, nil
Generation: account.Generation,
}
if op := account.TerminationOp; op != nil {
out.TerminationOp = &TerminationOp{OperationID: op.OperationID, Fence: op.Fence}
}
return out, nil
}
func (a *IAMAccounts) QuiesceAccount(accountID, operationID string, fence uint64) (uint64, error) {
@@ -136,6 +136,16 @@ type Account struct {
AccountID string
AccountName string
Status string
// Generation and TerminationOp are what a quiesce read-back compares.
Generation uint64
TerminationOp *TerminationOp
}
// TerminationOp mirrors the operation and fence IAM holds on a terminating account.
type TerminationOp struct {
OperationID string
Fence uint64
}
// Request is one teardown.
+11
View File
@@ -85,6 +85,9 @@ type accountDeletionJob struct {
Holder string `json:"holder,omitempty"`
LeaseUntil string `json:"leaseUntil,omitempty"`
Takeovers []deletionTakeover `json:"takeovers,omitempty"`
// IAM is the quiescence evaluation made under the current fence. A takeover
// starts without one and the new holder records its own.
IAM *operatorv1.IAMQuiescence `json:"iam,omitempty"`
}
// deletionTakeover records one change of holder.
@@ -258,6 +261,7 @@ func (d Deps) runAccountDeletion(
ctx, cancel := context.WithTimeout(context.Background(), accountDeletionTimeout)
tracker := &deletionTracker{jobs: jobs, job: job, revision: revision, cancel: cancel}
engine.OnStage = tracker.stageFinished
engine.OnQuiesced = tracker.quiesced
go func() {
defer cancel()
@@ -297,6 +301,13 @@ func (t *deletionTracker) stageFinished(ctx context.Context, stage accountteardo
t.persist(ctx)
}
func (t *deletionTracker) quiesced(ctx context.Context, quiescence operatorv1.IAMQuiescence) {
t.mu.Lock()
t.job.IAM = &quiescence
t.mu.Unlock()
t.persist(ctx)
}
func (t *deletionTracker) finished(ctx context.Context, result *accountteardown.Result, err error) {
t.mu.Lock()
if result != nil {
@@ -0,0 +1,141 @@
package admin
//test:in-package — the job record, the tracker and the takeover that clears the
// evaluation are unexported.
import (
"encoding/json"
"strings"
"sync/atomic"
"testing"
"time"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func sampleQuiescence(fence uint64) operatorv1.IAMQuiescence {
return operatorv1.IAMQuiescence{
OperationID: "op-1", Fence: fence, Generation: 4,
QuiescedScope: []string{},
Unresolved: []operatorv1.UnresolvedScope{{
Scope: "ec2-key-pair", Class: operatorv1.UnresolvedClassWriter,
Reason: operatorv1.ReasonFinalInventoryRereadNotWired,
}},
Consumers: []operatorv1.ConsumerBasis{{Consumer: "awsgw-admission", Basis: "ADR-0005:S7"}},
Writers: []operatorv1.AccountedWriter{{Writer: "daemon-nats-handlers", Basis: "owner-inventory", Ref: "mulga-tpoz4"}},
Inventory: []operatorv1.InventoryStatus{{Kind: "key-pair", Status: "observed-clean-at-scan-time"}},
EvaluatedAt: "2026-11-02T00:00:00Z",
}
}
// A normal deletion leaves the evaluation on the record, read back from the real
// IAM account record, and DescribeAccountDeletion returns it.
func TestDeletionRecordCarriesTheIAMQuiescence(t *testing.T) {
gw := newDeleteAccountGateway(t)
account := newTenantAccount(t, gw, "tenant@example.com")
_, err := gw.deleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken))
require.NoError(t, err)
require.Eventually(t, func() bool {
return describeDeletion(t, gw, account.AccountID).State == operatorv1.DeletionStateCompleted
}, 30*time.Second, 100*time.Millisecond)
stored := describeDeletion(t, gw, account.AccountID)
require.NotNil(t, stored.IAM)
assert.Equal(t, stored.OperationID, stored.IAM.OperationID)
assert.Equal(t, uint64(1), stored.IAM.Fence)
assert.Positive(t, stored.IAM.Generation)
assert.Empty(t, stored.IAM.QuiescedScope)
assert.NotEmpty(t, stored.IAM.Unresolved)
assert.Len(t, stored.IAM.Consumers, 3)
// The stubbed cluster answers the inventory subject with an empty body, which
// is an incomplete inventory, and recording that must not fail the teardown.
for _, kind := range []string{"ec2-key-pair", "ec2-placement-group", "ec2-launch-template"} {
var reasons []string
for _, entry := range stored.IAM.Unresolved {
if entry.Scope == kind {
reasons = append(reasons, entry.Reason)
}
}
assert.Equal(t, []string{operatorv1.ReasonOwnerInventoryIncomplete}, reasons, kind)
}
}
// The evaluation belongs to a fence: a holder that has been taken over cannot
// write it into the new holder's record.
func TestSupersededHolderCannotWriteTheQuiescence(t *testing.T) {
gw := newDeleteAccountGateway(t)
jobs, err := gw.accountDeletionStore(t.Context())
require.NoError(t, err)
const accountID = "000000000042"
holderA, revisionA, err := claimAs(t, jobs, accountID, strings.Repeat("a", 32), "node-a")
require.NoError(t, err)
var cancelled atomic.Bool
trackerA := &deletionTracker{jobs: jobs, job: holderA, revision: revisionA, cancel: func() { cancelled.Store(true) }}
expireLease(t, jobs, accountID)
_, _, err = claimAs(t, jobs, accountID, strings.Repeat("b", 32), "node-b")
require.NoError(t, err)
trackerA.quiesced(t.Context(), sampleQuiescence(1))
assert.True(t, cancelled.Load())
stored := describeDeletion(t, gw, accountID)
assert.Equal(t, uint64(2), stored.Fence)
assert.Nil(t, stored.IAM, "the old holder's evaluation must not land under the new fence")
}
// A takeover starts without the previous fence's evaluation, and the new holder
// records its own under the new fence.
func TestTakeoverClearsTheQuiescenceAndTheNewHolderRecordsItsOwn(t *testing.T) {
gw := newDeleteAccountGateway(t)
jobs, err := gw.accountDeletionStore(t.Context())
require.NoError(t, err)
const accountID = "000000000042"
holderA, revisionA, err := claimAs(t, jobs, accountID, strings.Repeat("a", 32), "node-a")
require.NoError(t, err)
trackerA := &deletionTracker{jobs: jobs, job: holderA, revision: revisionA}
trackerA.quiesced(t.Context(), sampleQuiescence(1))
require.NotNil(t, describeDeletion(t, gw, accountID).IAM)
expireLease(t, jobs, accountID)
holderB, revisionB, err := claimAs(t, jobs, accountID, strings.Repeat("b", 32), "node-b")
require.NoError(t, err)
assert.Nil(t, holderB.IAM)
assert.Nil(t, describeDeletion(t, gw, accountID).IAM, "the record shows no evaluation until the new holder makes one")
trackerB := &deletionTracker{jobs: jobs, job: holderB, revision: revisionB}
trackerB.quiesced(t.Context(), sampleQuiescence(2))
stored := describeDeletion(t, gw, accountID)
require.NotNil(t, stored.IAM)
assert.Equal(t, uint64(2), stored.IAM.Fence)
}
// The field is optional on the wire: a record from before it decodes without it
// and prints without it, and one with it keeps the lists as arrays.
func TestAdminWire_DeletionJobIAMIsOptional(t *testing.T) {
var old accountDeletionJob
require.NoError(t, json.Unmarshal([]byte(`{"deletionId":"d","accountId":"000000000042","state":"RUNNING"}`), &old))
assert.Nil(t, old.IAM)
encoded, err := json.Marshal(old)
require.NoError(t, err)
assert.NotContains(t, string(encoded), `"iam"`)
quiescence := sampleQuiescence(1)
data, err := json.Marshal(accountDeletionJob{DeletionID: "d", AccountID: "000000000042", State: "RUNNING", IAM: &quiescence})
require.NoError(t, err)
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(data, &raw))
goldenJSON(t, raw["iam"], `{
"operationId":"op-1","fence":1,"generation":4,"quiescedScope":[],
"unresolved":[{"scope":"ec2-key-pair","class":"writer","reason":"final-inventory-reread-not-wired"}],
"consumers":[{"consumer":"awsgw-admission","basis":"ADR-0005:S7"}],
"writers":[{"writer":"daemon-nats-handlers","basis":"owner-inventory","ref":"mulga-tpoz4"}],
"inventory":[{"kind":"key-pair","status":"observed-clean-at-scan-time"}],
"evaluatedAt":"2026-11-02T00:00:00Z"}`)
}
@@ -0,0 +1,183 @@
// Package guestconn is the host end of the Lambda guest control connection:
// per-generation identities and an authenticating TCP accept loop.
package guestconn
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"math/big"
"net"
"net/url"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/pki"
)
// Generation is the host's material for one environment generation: its own
// identity and the one guest certificate it accepts. Nothing is reused.
type Generation struct {
Scope lambdav1.Scope
host tls.Certificate
hostPEM []byte
guestCert *x509.Certificate
guestPEM []byte
guestKey []byte
}
// NewGeneration creates fresh self-signed host and guest identities for
// scope, valid from a minute ago for ttl.
func NewGeneration(scope lambdav1.Scope, ttl time.Duration) (*Generation, error) {
if _, err := lambdav1.ParseScope(string(scope)); err != nil {
return nil, err
}
if ttl <= 0 {
return nil, errors.New("guestconn: generation ttl must be positive")
}
hostPEM, hostKey, _, err := selfSigned(scope, lambdav1.RoleHost, ttl)
if err != nil {
return nil, err
}
host, err := tls.X509KeyPair(hostPEM, hostKey)
if err != nil {
return nil, fmt.Errorf("guestconn: host key pair: %w", err)
}
guestPEM, guestKey, guestCert, err := selfSigned(scope, lambdav1.RoleGuest, ttl)
if err != nil {
return nil, err
}
return &Generation{Scope: scope, host: host, hostPEM: hostPEM, guestCert: guestCert, guestPEM: guestPEM, guestKey: guestKey}, nil
}
func selfSigned(scope lambdav1.Scope, role string, ttl time.Duration) (certPEM, keyPEM []byte, cert *x509.Certificate, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, nil, fmt.Errorf("guestconn: %s key: %w", role, err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
if err != nil {
return nil, nil, nil, fmt.Errorf("guestconn: %s serial: %w", role, err)
}
u, err := url.Parse(scope.URI(role))
if err != nil {
return nil, nil, nil, fmt.Errorf("guestconn: %s URI: %w", role, err)
}
usage := x509.ExtKeyUsageServerAuth
if role == lambdav1.RoleGuest {
usage = x509.ExtKeyUsageClientAuth
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial, Subject: pkix.Name{CommonName: "spinifex-lambda " + role},
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(ttl),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage},
BasicConstraintsValid: true, URIs: []*url.URL{u},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, nil, nil, fmt.Errorf("guestconn: %s certificate: %w", role, err)
}
kder, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, nil, nil, fmt.Errorf("guestconn: %s key encoding: %w", role, err)
}
if cert, err = x509.ParseCertificate(der); err != nil {
return nil, nil, nil, fmt.Errorf("guestconn: %s certificate: %w", role, err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kder}), cert, nil
}
// GuestEntries returns the fw_cfg entries the guest needs, by entry name. The
// guest key is secret: the caller writes these 0600 and deletes them once
// QEMU has read them.
func (g *Generation) GuestEntries(hostAddr string) map[string][]byte {
return map[string][]byte{
lambdav1.EntryGuestKey: append([]byte(nil), g.guestKey...),
lambdav1.EntryGuestCert: append([]byte(nil), g.guestPEM...),
lambdav1.EntryHostCert: append([]byte(nil), g.hostPEM...),
lambdav1.EntryScope: []byte(g.Scope),
lambdav1.EntryHostAddr: []byte(hostAddr),
}
}
// ServerConfig is the host's TLS 1.3 configuration: it requires a client
// certificate and accepts only this generation's guest certificate.
func (g *Generation) ServerConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{g.host},
NextProtos: []string{lambdav1.ALPNv1},
ClientAuth: tls.RequireAnyClientCert,
VerifyConnection: func(cs tls.ConnectionState) error {
if cs.NegotiatedProtocol != lambdav1.ALPNv1 {
return fmt.Errorf("guestconn: application protocol %q, want %q", cs.NegotiatedProtocol, lambdav1.ALPNv1)
}
return pki.VerifyPinned(cs.PeerCertificates, g.guestCert, x509.ExtKeyUsageClientAuth, g.Scope.URI(lambdav1.RoleGuest))
},
}
}
// DeadlineListener is a listener whose Accept can be interrupted.
type DeadlineListener interface {
net.Listener
SetDeadline(t time.Time) error
}
// AcceptGuest returns the first connection that authenticates as this
// generation's guest. Peers on the untrusted network may connect first; each
// gets handshakeTimeout and is closed on failure. It stops when ctx ends.
func (g *Generation) AcceptGuest(ctx context.Context, ln DeadlineListener, handshakeTimeout time.Duration) (*tls.Conn, error) {
if handshakeTimeout <= 0 {
return nil, errors.New("guestconn: handshake timeout must be positive")
}
_ = ln.SetDeadline(time.Time{})
fired := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
_ = ln.SetDeadline(time.Now())
close(fired)
})
// If the callback started, wait for it so its expired deadline cannot
// land after the clear and leave the listener unusable.
defer func() {
if !stop() {
<-fired
_ = ln.SetDeadline(time.Time{})
}
}()
cfg := g.ServerConfig()
for {
raw, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("guestconn: no authenticated guest: %w", context.Cause(ctx))
}
return nil, fmt.Errorf("guestconn: accept: %w", err)
}
if c := g.handshake(ctx, raw, cfg, handshakeTimeout); c != nil {
return c, nil
}
}
}
func (g *Generation) handshake(ctx context.Context, raw net.Conn, cfg *tls.Config, timeout time.Duration) *tls.Conn {
hctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
tc := tls.Server(raw, cfg)
if err := tc.HandshakeContext(hctx); err != nil {
slog.Warn("guestconn: refused control connection", "remote", raw.RemoteAddr().String(), "err", err)
_ = raw.Close()
return nil
}
return tc
}
@@ -0,0 +1,233 @@
package guestconn
import (
"context"
"errors"
"io"
"net"
"os"
"path/filepath"
"sync"
"testing"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
gen1 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
gen2 lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-2"
)
func writeEntries(t *testing.T, entries map[string][]byte) string {
t.Helper()
dir := t.TempDir()
for name, b := range entries {
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, name, "raw"), b, 0o600))
}
return dir
}
func listen(t *testing.T) *net.TCPListener {
t.Helper()
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
return ln
}
func newGeneration(t *testing.T, scope lambdav1.Scope) *Generation {
t.Helper()
g, err := NewGeneration(scope, time.Hour)
require.NoError(t, err)
return g
}
// guestFor loads the guest identity from g's fw_cfg entries, as the guest
// agent would.
func guestFor(t *testing.T, g *Generation, addr string) hostlink.Identity {
t.Helper()
id, err := hostlink.LoadIdentity(writeEntries(t, g.GuestEntries(addr)))
require.NoError(t, err)
return id
}
type accepted struct {
conn net.Conn
err error
}
func acceptAsync(g *Generation, ln DeadlineListener, ctx context.Context, timeout time.Duration) <-chan accepted {
out := make(chan accepted, 1)
go func() {
c, err := g.AcceptGuest(ctx, ln, timeout)
out <- accepted{c, err}
}()
return out
}
func TestHostGuestExchange(t *testing.T) {
ln := listen(t)
g := newGeneration(t, gen1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
hostSide := acceptAsync(g, ln, ctx, 5*time.Second)
guest, err := hostlink.Dial(ctx, guestFor(t, g, ln.Addr().String()))
require.NoError(t, err)
defer guest.Close()
host := <-hostSide
require.NoError(t, host.err)
defer host.conn.Close()
_, err = host.conn.Write([]byte("host->guest"))
require.NoError(t, err)
buf := make([]byte, 11)
_, err = io.ReadFull(guest, buf)
require.NoError(t, err)
assert.Equal(t, "host->guest", string(buf))
_, err = guest.Write([]byte("guest->host"))
require.NoError(t, err)
_, err = io.ReadFull(host.conn, buf)
require.NoError(t, err)
assert.Equal(t, "guest->host", string(buf))
}
func TestGuestEntriesUseTheNamesTheGuestReads(t *testing.T) {
g := newGeneration(t, gen1)
id := guestFor(t, g, "10.0.0.1:1")
assert.Equal(t, gen1, id.Scope)
assert.Equal(t, gen1.URI(lambdav1.RoleHost), id.HostCert.URIs[0].String())
assert.Equal(t, gen1.URI(lambdav1.RoleGuest), id.Cert.Leaf.URIs[0].String())
}
// A wrong guest is refused and the host keeps listening for the right one;
// so is a peer that connects and never handshakes.
func TestHostRefusesWrongGuestThenAcceptsTheRightOne(t *testing.T) {
ln := listen(t)
g := newGeneration(t, gen1)
addr := ln.Addr().String()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
hostSide := acceptAsync(g, ln, ctx, 300*time.Millisecond)
silent, err := net.Dial("tcp", addr)
require.NoError(t, err)
defer silent.Close()
for name, wrong := range map[string]hostlink.Identity{
"same scope, other generation's keys": guestFor(t, newGeneration(t, gen1), addr),
"later generation": guestFor(t, newGeneration(t, gen2), addr),
} {
good := guestFor(t, g, addr)
wrong.HostCert, wrong.Scope = good.HostCert, good.Scope // trust the real host, present the wrong guest
c, err := hostlink.Dial(ctx, wrong)
if err == nil {
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
_, err = c.Read(make([]byte, 1))
c.Close()
}
require.Error(t, err, name)
assert.Contains(t, err.Error(), "bad certificate", name)
}
right, err := hostlink.Dial(ctx, guestFor(t, g, addr))
require.NoError(t, err)
defer right.Close()
host := <-hostSide
require.NoError(t, host.err)
host.conn.Close()
}
// gatedListener holds the cancel callback's expiring SetDeadline until the
// test releases it, so a clear that does not wait would land first.
type gatedListener struct {
entered, gate, cleared chan struct{}
mu sync.Mutex
deadline time.Time
calls int
}
func (l *gatedListener) Accept() (net.Conn, error) {
<-l.entered
return nil, errors.New("listener deadline expired")
}
func (l *gatedListener) Close() error { return nil }
func (l *gatedListener) Addr() net.Addr { return &net.TCPAddr{} }
func (l *gatedListener) SetDeadline(d time.Time) error {
if !d.IsZero() {
close(l.entered)
<-l.gate
}
l.mu.Lock()
defer l.mu.Unlock()
l.calls++
l.deadline = d
if d.IsZero() && l.calls > 1 {
close(l.cleared)
}
return nil
}
func TestAcceptGuestClearsDeadlineAfterCancelCallback(t *testing.T) {
ln := &gatedListener{entered: make(chan struct{}), gate: make(chan struct{}), cleared: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
hostSide := acceptAsync(newGeneration(t, gen1), ln, ctx, time.Second)
cancel()
<-ln.entered
// A clear that does not wait for the callback arrives now; one that does
// cannot, so the bounded wait only delays the passing case.
select {
case <-ln.cleared:
case <-time.After(200 * time.Millisecond):
}
close(ln.gate)
require.ErrorIs(t, (<-hostSide).err, context.Canceled)
ln.mu.Lock()
defer ln.mu.Unlock()
assert.True(t, ln.deadline.IsZero(), "listener left with expired deadline %v", ln.deadline)
assert.Equal(t, 3, ln.calls)
}
func TestAcceptGuestStopsWithContext(t *testing.T) {
ln := listen(t)
g := newGeneration(t, gen1)
ctx, cancel := context.WithCancelCause(context.Background())
hostSide := acceptAsync(g, ln, ctx, time.Second)
reason := errors.New("environment superseded")
cancel(reason)
select {
case host := <-hostSide:
assert.ErrorIs(t, host.err, reason)
case <-time.After(5 * time.Second):
t.Fatal("AcceptGuest did not return after cancellation")
}
// The listener is usable again by a later call.
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
hostSide = acceptAsync(g, ln, ctx2, time.Second)
c, err := hostlink.Dial(ctx2, guestFor(t, g, ln.Addr().String()))
require.NoError(t, err)
defer c.Close()
host := <-hostSide
require.NoError(t, host.err)
host.conn.Close()
}
func TestNewGenerationRejects(t *testing.T) {
_, err := NewGeneration("a/b/c", time.Hour)
assert.ErrorContains(t, err, "want 5 segments")
_, err = NewGeneration(gen1, 0)
assert.ErrorContains(t, err, "ttl must be positive")
_, err = newGeneration(t, gen1).AcceptGuest(context.Background(), listen(t), 0)
assert.ErrorContains(t, err, "handshake timeout must be positive")
}
-2
View File
@@ -36,7 +36,6 @@ type MicroVMSpec struct {
FwCfg []FwCfgEntry
NetDevs []NetDev
Devices []Device
SerialPorts []SerialPort
}
// NewMicroVMConfig builds the Config for a direct-boot microvm launch: qboot,
@@ -59,7 +58,6 @@ func NewMicroVMConfig(s MicroVMSpec) Config {
FwCfg: s.FwCfg,
NetDevs: s.NetDevs,
Devices: s.Devices,
SerialPorts: s.SerialPorts,
}
}
-40
View File
@@ -468,17 +468,6 @@ type Config struct {
Initrd string `json:"initrd,omitempty"` // path to initramfs; emits -initrd when set
KernelCmdline string `json:"kernel_cmdline,omitempty"` // emits -append when set
FwCfg []FwCfgEntry `json:"fw_cfg,omitempty"` // each emits -fw_cfg name=<name>,file=<path>
// SerialPorts are named virtio-serial ports, each backed by a host unix
// socket that QEMU listens on. They carry no network traffic.
SerialPorts []SerialPort `json:"serial_ports,omitempty"`
}
// SerialPort is one virtserialport named Name in the guest, whose host end is
// a unix socket at Socket that QEMU creates and listens on.
type SerialPort struct {
Name string `json:"name"`
Socket string `json:"socket"`
}
// FwCfgEntry describes a single QEMU firmware configuration file entry.
@@ -683,12 +672,6 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-fw_cfg", fmt.Sprintf("name=%s,file=%s", fw.Name, fw.File))
}
portArgs, err := serialPortArgs(cfg.SerialPorts)
if err != nil {
return nil, err
}
args = append(args, portArgs...)
if cfg.Architecture == "" {
return nil, fmt.Errorf("architecture missing")
}
@@ -717,29 +700,6 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
return cmd, nil
}
// serialPortArgs renders one virtio-serial controller and, per port, a
// listening socket chardev and its virtserialport. Commas are refused because
// QEMU would read them as further options.
func serialPortArgs(ports []SerialPort) ([]string, error) {
if len(ports) == 0 {
return nil, nil
}
args := []string{"-device", "virtio-serial-pci,id=vserial0"}
for i, p := range ports {
if p.Name == "" || p.Socket == "" {
return nil, fmt.Errorf("serial port %d: name and socket are required", i)
}
if strings.ContainsRune(p.Name, ',') || strings.ContainsRune(p.Socket, ',') {
return nil, fmt.Errorf("serial port %q: name and socket must not contain a comma", p.Name)
}
id := fmt.Sprintf("vport%d", i)
args = append(args,
"-chardev", fmt.Sprintf("socket,id=%s,path=%s,server=on,wait=off", id, p.Socket),
"-device", fmt.Sprintf("virtserialport,bus=vserial0.0,chardev=%s,name=%s", id, p.Name))
}
return args, nil
}
// appendOpt renders a chardev append option, or nothing when off, so a
// fresh launch keeps the command line it always had.
func appendOpt(key string, on bool) string {
-52
View File
@@ -694,55 +694,3 @@ func TestExecute_MicrovmWithPCIEFileChardev(t *testing.T) {
assert.Equal(t, "file,id=console0,path=/var/log/console.log", argValue(args, "-chardev"))
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
}
func TestExecute_SerialPortsRenderListeningSocketsAndPorts(t *testing.T) {
cfg := NewMicroVMConfig(MicroVMSpec{
Name: "lambda", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256,
ImageDir: "/img",
SerialPorts: []SerialPort{
{Name: "lambda.ctl", Socket: "/run/env/ctl.sock"},
{Name: "second", Socket: "/run/env/second.sock"},
},
})
cmd, err := cfg.Execute()
require.NoError(t, err)
args := cmd.Args[1:]
assert.Equal(t, []string{
"socket,id=vport0,path=/run/env/ctl.sock,server=on,wait=off",
"socket,id=vport1,path=/run/env/second.sock,server=on,wait=off",
}, allArgValues(args, "-chardev"))
devs := allArgValues(args, "-device")
assert.Equal(t, 1, strings.Count(strings.Join(devs, " "), "virtio-serial-pci"))
assert.Contains(t, devs, "virtio-serial-pci,id=vserial0")
assert.Contains(t, devs, "virtserialport,bus=vserial0.0,chardev=vport0,name=lambda.ctl")
assert.Contains(t, devs, "virtserialport,bus=vserial0.0,chardev=vport1,name=second")
}
func TestExecute_NoSerialPortsAddsNoVirtioSerial(t *testing.T) {
cfg := NewMicroVMConfig(MicroVMSpec{Name: "lb", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256, ImageDir: "/img"})
cmd, err := cfg.Execute()
require.NoError(t, err)
assert.NotContains(t, strings.Join(cmd.Args, " "), "virtio-serial")
assert.NotContains(t, strings.Join(cmd.Args, " "), "virtserialport")
}
func TestExecute_SerialPortRefusesOptionInjection(t *testing.T) {
for _, tc := range []struct {
port SerialPort
want string
}{
{SerialPort{Name: "ctl,id=x", Socket: "/run/ctl.sock"}, "must not contain a comma"},
{SerialPort{Name: "ctl", Socket: "/run/ctl.sock,server=off"}, "must not contain a comma"},
{SerialPort{Name: "", Socket: "/run/ctl.sock"}, "name and socket are required"},
{SerialPort{Name: "ctl", Socket: ""}, "name and socket are required"},
} {
cfg := NewMicroVMConfig(MicroVMSpec{
Name: "lambda", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256,
ImageDir: "/img", SerialPorts: []SerialPort{tc.port},
})
_, err := cfg.Execute()
require.Error(t, err, "port %+v", tc.port)
assert.Contains(t, err.Error(), tc.want, "port %+v", tc.port)
}
}