Files
spinifex/cmd/lambda-agent/start_testlink.go
T
Josh Siviour 93d14a4a24 feat(lambda): deliver and revoke an execution-role session per test-link Invoke (#1635)
* feat(sts): revoke one Lambda environment session by scope and revision

* fix(sts): keep the access key out of Lambda session revoke errors

* feat(lambda): add the test-link control socket with peer UID checks

* fix(lambda): bound the test-link reply wait and keep cancellation sticky

* fix(lambda): cap the test-link write by the reply bound and refuse a zero bound

* test(lambda): bound the control-socket peer waits and surface peer errors

* feat(lambda): issue and revoke the execution session around each test-link Invoke

* feat(lambda): serve test-link requests on the control socket and deliver the execution credential to the guest

The daemon answers the gateway on the abstract control socket instead of
NATS and hands the guest the execution-role credential in its first frame.
Test-link builds negotiate spinifex-lambda-testlink/2 on both ends, so a
mismatched host and guest fail the handshake before any request is sent,
and the agent refuses a frame without a credential.

* test(lambda): run the composed test-link seam on real STS and a real control socket

The seam now issues and revokes sessions through the real STS service and
reaches the daemon's attempt steps over a real abstract control socket.
New cases show the guest's credential resolving as the role session with
its Lambda labels under the base ARN until Invoke returns, both orders of a
give-up revoke against the first frame, a failed revoke staying unknown
without the key in logs, a write error after full delivery being unknown
and revoked, and legacy NATS forgeries never reaching the caller.

* test(lambda): show the test-link daemon refuses only before the VM starts

A started process whose launch identity cannot be recorded replies unknown,
proven with a stub VM that is alive when its identity is read and an
injected write failure. A source check pins every refused reply in the
launch path before the VM start. The files the attempt leaves, its reply
and the log carry neither the credential nor the code URL.

* test(lambda): fail a delivered write at the control client and bound the seam waits

A test-only writer at the control client's write boundary forwards both
frames, waits until the daemon has read them, then fails the write; the
client reports an outcome that is not ErrNotSent. Seam tests now bound every
wait, open their gates and cancel their contexts at cleanup, and join the
inner exchange they start.

* docs(lambda): note the test-link ALPN protocol in the guest contract README

* test(sts): satisfy lint in the Lambda session revoke tests

* test(lambda): mark the new in-package test-link tests with their reason

* fix(lambda): report a refused test-link attempt with a failed revoke as unknown and run the handshake tests in CI
2026-11-05 15:21:39 +10:00

194 lines
5.7 KiB
Go

//go:build lambdatestlink
package main
import (
"context"
"errors"
"fmt"
"io"
"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"
)
// testlink relays one invocation over the test carrier on the authenticated
// connection; the carrier is not the guest protocol.
type testlink struct {
startup
pkg taskPackage
runtimeAddr string
startBootstrap func(ctx context.Context, env []string) (*exec.Cmd, error)
}
func start(ctx context.Context) int {
t := testlink{
startup: defaultStartup(),
pkg: devTaskPackage(),
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) {
// The package values are fw_cfg entries, so read them before connect seals it.
spec, specErr := readPackageSpec(t.fwCfgDir)
grant, grantErr := readGrant(t.fwCfgDir)
conn, scope, code, err := t.connect(parent)
if err != nil {
return code, errors.Join(err, specErr, grantErr)
}
defer conn.Close()
if err := errors.Join(specErr, grantErr); err != nil {
return exitRefused, err
}
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)
}
// Nothing of the tenant's is installed or run for an unbound request.
if err := checkBinding(scope, grant, first, time.Now()); err != nil {
return exitRefused, err
}
// A host that sent no credential is refused before anything is installed.
if first.Credential == nil || !first.Credential.Complete() {
return exitRefused, errors.New("first frame carries no execution credential")
}
cred := *first.Credential
first.Credential = nil
if err := t.pkg.install(spec); err != nil {
return exitRefused, fmt.Errorf("install package: %w", err)
}
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, bootstrapEnv(ln.Addr().String(), spec.handler, cred))
if err != nil {
return exitLink, fmt.Errorf("start bootstrap: %w", err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
err := cmd.Wait()
// Once a response is being received the result stands, so only an
// exit before that cancels the invocation.
if srv.BootstrapExited() {
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
}
// bootstrapEnv is the bootstrap's whole environment: the runtime variables and
// the execution role's three credential variables.
func bootstrapEnv(runtimeAPI, handler string, cred hostlink.Credential) []string {
return []string{
"AWS_LAMBDA_RUNTIME_API=" + runtimeAPI,
"_HANDLER=" + handler,
"LAMBDA_TASK_ROOT=" + taskRoot,
"PATH=/bin:/sbin:/usr/bin",
"AWS_ACCESS_KEY_ID=" + cred.AccessKeyID,
"AWS_SECRET_ACCESS_KEY=" + cred.SecretAccessKey,
"AWS_SESSION_TOKEN=" + cred.SessionToken,
}
}
// shutdownGrace is how long past the invocation deadline the agent waits for
// the host's shutdown frame once its result is written.
const shutdownGrace = 2 * time.Second
// oneInvocation serves the host's first request and then stops the agent:
// each test VM runs a single invocation. After the result is written it waits
// for the host's shutdown frame or EOF, so the result is not cut off by the
// poweroff that follows the agent's exit.
type oneInvocation struct {
hostlink.Link
first *hostlink.Request
deadlineMs int64
}
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 {
req := *o.first
o.first = nil
o.deadlineMs = req.DeadlineMs
return req, nil
}
return hostlink.Request{}, o.awaitShutdown(ctx)
}
// awaitShutdown reads the host's next frame and always ends the agent: with
// ErrShutdown for a shutdown frame, EOF, a stray frame or an expired wait.
func (o *oneInvocation) awaitShutdown(ctx context.Context) error {
end := time.UnixMilli(o.deadlineMs)
if now := time.Now(); end.Before(now) {
end = now
}
wctx, cancel := context.WithDeadline(ctx, end.Add(shutdownGrace))
defer cancel()
_, err := o.Link.Next(wctx)
switch {
case errors.Is(err, hostlink.ErrShutdown), errors.Is(err, io.EOF):
return hostlink.ErrShutdown
case err == nil:
slog.Warn("lambda-agent: frame after the invocation was not dispatched")
return hostlink.ErrShutdown
case ctx.Err() != nil:
return err
case wctx.Err() != nil:
slog.Warn("lambda-agent: no shutdown from the host; exiting")
return hostlink.ErrShutdown
}
return err
}