mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-06 02:51:30 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4be471c196 | |||
| ce29eb12bf | |||
| 8ef35a9c1d |
@@ -16,8 +16,8 @@ load_mod() {
|
||||
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
|
||||
# lambda-agent reads its identity from fw_cfg, or the configuration disk, and
|
||||
# seals it 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
|
||||
@@ -26,8 +26,30 @@ load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
|
||||
|
||||
ip link set lo up
|
||||
|
||||
# Without fw_cfg data the executor attaches the same entries as a tar archive
|
||||
# on the second disk; lambda-agent reads them here and erases the disk before
|
||||
# the function starts. qemu_fw_cfg loads without a device, so test the entry.
|
||||
CFG_ROOT=/spinifex-cfg
|
||||
FWCFG_ROOT=/sys/firmware/qemu_fw_cfg
|
||||
if [ ! -f "$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw" ]; then
|
||||
load_mod virtio_blk || echo "[init] virtio_blk did not load" >/dev/console
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -b /dev/vdb ]; do
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ -b /dev/vdb ]; then
|
||||
mkdir -m 0700 "$CFG_ROOT"
|
||||
if ! mount -t tmpfs -o mode=0700,size=1m tmpfs "$CFG_ROOT" ||
|
||||
! tar -xf /dev/vdb -C "$CFG_ROOT"; then
|
||||
echo "[init] configuration disk not readable" >/dev/console
|
||||
fi
|
||||
FWCFG_ROOT=$CFG_ROOT
|
||||
fi
|
||||
fi
|
||||
|
||||
# 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
|
||||
NETCFG=$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
|
||||
sleep 0.04
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
@@ -25,9 +26,22 @@ type startup struct {
|
||||
}
|
||||
|
||||
func defaultStartup() startup {
|
||||
if _, err := os.Stat(hostlink.ConfigDiskRoot); err == nil {
|
||||
return configDiskStartup(hostlink.ConfigDiskRoot, hostlink.ConfigDiskDevice)
|
||||
}
|
||||
return startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
|
||||
}
|
||||
|
||||
// configDiskStartup reads the entries init extracted from the configuration
|
||||
// disk, and seals by erasing the disk and removing the extracted tree.
|
||||
func configDiskStartup(root, device string) startup {
|
||||
return startup{
|
||||
fwCfgDir: root + "/by_name/" + lambdav1.FwCfgPrefix,
|
||||
fwCfgRoot: root,
|
||||
unload: func() error { return hostlink.SealConfigDisk(device, root) },
|
||||
}
|
||||
}
|
||||
|
||||
// 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, lambdav1.Scope, int, error) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//test:in-package — package main cannot be imported by an external test package.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An identity that does not load still seals the configuration disk: the
|
||||
// guest key on it is erased before the agent refuses to start.
|
||||
func TestConfigDiskStartup_RefusedLoadErasesDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
device := filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, bytes.Repeat([]byte("guest-key"), 512), 0o600))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, device).connect(t.Context())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, exitRefused, code)
|
||||
got, rerr := os.ReadFile(device)
|
||||
require.NoError(t, rerr)
|
||||
require.Len(t, got, 9*512)
|
||||
require.Equal(t, make([]byte, len(got)), got, "configuration disk erased")
|
||||
}
|
||||
|
||||
// A disk that cannot be erased fails closed: the agent refuses, and the
|
||||
// extracted tree is kept so the seal check sees it.
|
||||
func TestConfigDiskStartup_UnerasableDiskRefuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, filepath.Join(dir, "missing")).connect(t.Context())
|
||||
require.ErrorContains(t, err, "erase configuration disk")
|
||||
require.Equal(t, exitRefused, code)
|
||||
require.DirExists(t, root)
|
||||
}
|
||||
@@ -28,6 +28,9 @@ Its encoded frame is at most the base64 length of 6 MiB plus 64 KiB of metadata
|
||||
The credential travels as its own frame of at most 8 KiB, and the reply has the same payload and frame bounds as the request.
|
||||
The daemon holds at most two connections at once, from accept to close, and leaves the rest unaccepted in the listen backlog.
|
||||
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.
|
||||
In the same builds, the function owner moves a created function with stored code from `Pending` to `Active` once this node's daemon serves the pinned image and the cluster configuration has exactly one node; a function with environment variables or a code copy over the test-link bound stays `Pending`, and `StateReason` names the unmet condition.
|
||||
Release builds never leave `Pending`.
|
||||
The Mulga deploy role installs a test-link build with `deploy_lambda_testlink=true`; this profile does not meet Q-199 fail-closed revocation, and its guests take management addresses whose record can expire (mulga-0hsub).
|
||||
|
||||
**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.
|
||||
|
||||
@@ -41,6 +44,8 @@ Under TLS 1.3 the guest's handshake can complete before the host refuses its cer
|
||||
|
||||
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.
|
||||
On a VMM without fw_cfg (the test-link Firecracker executor), the executor attaches the same entries as a tar archive on the second, writable virtio-blk disk, laid out as `by_name/<entry>/raw`.
|
||||
The runtime image's init extracts it to a root-only tmpfs at `/spinifex-cfg`, and the agent reads the entries there, then overwrites the whole disk with zeros and removes the tree; it refuses to start if the tree is still present.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// On a VMM without fw_cfg the executor attaches the same entries as a tar
|
||||
// archive on a second disk, which the runtime image's init extracts here in
|
||||
// the fw_cfg sysfs layout, so the fw_cfg readers work unchanged.
|
||||
const (
|
||||
ConfigDiskRoot = "/spinifex-cfg"
|
||||
ConfigDiskDir = ConfigDiskRoot + "/by_name/" + lambdav1.FwCfgPrefix
|
||||
ConfigDiskDevice = "/dev/vdb"
|
||||
)
|
||||
|
||||
// zeroChunk bounds each write while erasing the configuration disk.
|
||||
const zeroChunk = 64 << 10
|
||||
|
||||
// unmount is a var so tests without privilege can stand in for the tmpfs.
|
||||
var unmount = unix.Unmount
|
||||
|
||||
// SealConfigDisk overwrites the whole configuration disk with zeros, then
|
||||
// removes the extracted tree. The tree stays if the disk cannot be erased, so
|
||||
// SealFwCfg's check on root fails closed.
|
||||
func SealConfigDisk(device, root string) error {
|
||||
if err := zeroDevice(device); err != nil {
|
||||
return fmt.Errorf("hostlink: erase configuration disk: %w", err)
|
||||
}
|
||||
if err := unmount(root, 0); err != nil && !errors.Is(err, unix.EINVAL) {
|
||||
return fmt.Errorf("hostlink: unmount %s: %w", root, err)
|
||||
}
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
return fmt.Errorf("hostlink: remove %s: %w", root, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zeroDevice(device string) error {
|
||||
f, err := os.OpenFile(device, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size, err := f.Seek(0, io.SeekEnd)
|
||||
if err == nil {
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
}
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
zeros := make([]byte, zeroChunk)
|
||||
for left := size; left > 0; {
|
||||
n := min(left, int64(len(zeros)))
|
||||
if _, err := f.Write(zeros[:n]); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
left -= n
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//test:in-package reaches the unexported configDisk and fwCfgTree readers to seal and read a real archive
|
||||
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// configDisk writes a fake disk holding secret and an extracted tree; the
|
||||
// stand-in unmount reports the tree is not a mount, as on a plain directory.
|
||||
func configDisk(t *testing.T, secret []byte) (device, root string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
device = filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, append(bytes.Repeat([]byte{0xa5}, 3*zeroChunk+7), secret...), 0o600))
|
||||
root = fwCfgTree(t)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "by_name", "key"), secret, 0o600))
|
||||
old := unmount
|
||||
unmount = func(string, int) error { return unix.EINVAL }
|
||||
t.Cleanup(func() { unmount = old })
|
||||
return device, root
|
||||
}
|
||||
|
||||
func TestSealConfigDisk(t *testing.T) {
|
||||
secret := []byte("guest-key-material")
|
||||
|
||||
t.Run("erases the disk and removes the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
before, err := os.Stat(device)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
|
||||
disk, err := os.ReadFile(device)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, disk, int(before.Size()))
|
||||
assert.Equal(t, make([]byte, len(disk)), disk, "configuration disk not fully erased")
|
||||
_, err = os.Stat(root)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
t.Run("an unwritable disk keeps the tree and fails closed", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
require.NoError(t, os.Chmod(device, 0o400))
|
||||
err := SealFwCfg(root, func() error { return SealConfigDisk(device, root) })
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "still readable")
|
||||
_, err = os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
t.Run("a failed unmount keeps the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
unmount = func(string, int) error { return unix.EBUSY }
|
||||
require.Error(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
_, err := os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package function
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Readiness reports why a function whose code is stored cannot run yet, or nil
|
||||
// when it can. Its error text becomes the function's StateReason.
|
||||
type Readiness func(ctx context.Context, rec Record) error
|
||||
|
||||
// SetReadiness enables activation. Without it no function ever leaves Pending.
|
||||
func (s *MetaService) SetReadiness(r Readiness) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ready = r
|
||||
if s.nudge == nil {
|
||||
s.nudge = make(chan struct{}, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetaService) readiness() (Readiness, chan struct{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.ready, s.nudge
|
||||
}
|
||||
|
||||
// wake asks the activation loop for an early pass; it never blocks.
|
||||
func (s *MetaService) wake() {
|
||||
if _, nudge := s.readiness(); nudge != nil {
|
||||
select {
|
||||
case nudge <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunActivation activates pending functions until ctx ends: once at start, on
|
||||
// every code-stored completion, and every interval, so a crash between storing
|
||||
// the code and activating it is converged by the next pass.
|
||||
func (s *MetaService) RunActivation(ctx context.Context, interval time.Duration) {
|
||||
ready, nudge := s.readiness()
|
||||
if ready == nil {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if err := s.ActivatePending(ctx); err != nil && ctx.Err() == nil {
|
||||
slog.Warn("Lambda: activation pass incomplete; retrying", "err", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-nudge:
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ActivatePending makes one pass over every account's functions, moving each
|
||||
// Pending function with stored code to Active when readiness allows it.
|
||||
func (s *MetaService) ActivatePending(ctx context.Context) error {
|
||||
ready, _ := s.readiness()
|
||||
if ready == nil {
|
||||
return nil
|
||||
}
|
||||
var accounts []string
|
||||
names := s.js.KeyValueStoreNames(ctx)
|
||||
for name := range names.Name() {
|
||||
if id, ok := strings.CutPrefix(name, KVBucketAccountPrefix); ok {
|
||||
accounts = append(accounts, id)
|
||||
}
|
||||
}
|
||||
if err := names.Error(); err != nil {
|
||||
return fmt.Errorf("lambda: list account buckets: %w", err)
|
||||
}
|
||||
var errs []error
|
||||
for _, accountID := range accounts {
|
||||
if err := s.activateAccount(ctx, accountID, ready); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *MetaService) activateAccount(ctx context.Context, accountID string, ready Readiness) error {
|
||||
kv, err := s.bucket(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := kv.ListKeysFiltered(ctx, kvFunctionKeyPrefix+">")
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("lambda: list functions of account %s: %w", accountID, err)
|
||||
}
|
||||
var errs []error
|
||||
for key := range keys.Keys() {
|
||||
if err := s.activateOne(ctx, kv, key, ready); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// activateOne writes the readiness outcome by compare-and-swap at the revision
|
||||
// it read, so a concurrent update or delete wins and is never overwritten.
|
||||
func (s *MetaService) activateOne(ctx context.Context, kv jetstream.KeyValue, key string, ready Readiness) error {
|
||||
entry, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("lambda: read %s: %w", key, err)
|
||||
}
|
||||
rec, err := decode(entry.Value())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.State != StatePending || rec.Code.State != CodeStored {
|
||||
return nil
|
||||
}
|
||||
if notReady := ready(ctx, rec); notReady != nil {
|
||||
if rec.StateReason == notReady.Error() {
|
||||
return nil
|
||||
}
|
||||
rec.StateReasonCode, rec.StateReason = ReasonCodeCreating, notReady.Error()
|
||||
} else {
|
||||
rec.State, rec.StateReasonCode, rec.StateReason = StateActive, "", ""
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := kv.Update(ctx, key, data, entry.Revision()); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("lambda: write readiness of %s: %w", key, err)
|
||||
}
|
||||
if rec.State == StateActive {
|
||||
slog.Info("Lambda: function active", "account_id", rec.AccountID, "region", rec.Region,
|
||||
"function_name", rec.FunctionName, "function_id", rec.FunctionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package function_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
actAccount = "000000000001"
|
||||
actRegion = "ap-southeast-2"
|
||||
actDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
)
|
||||
|
||||
func newOwner(t *testing.T) *function.MetaService {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
return function.NewMetaService(testutil.NewJetStream(t, nc))
|
||||
}
|
||||
|
||||
func create(t *testing.T, svc *function.MetaService, name string) uint64 {
|
||||
t.Helper()
|
||||
resp, err := svc.Create(t.Context(), &function.CreateRequest{Region: actRegion, Record: function.Record{
|
||||
FunctionID: "id-" + name, FunctionName: name, Role: "arn:aws:iam::000000000001:role/r",
|
||||
Runtime: "provided.al2023", Handler: "bootstrap", Timeout: 3, MemorySize: 128,
|
||||
Code: function.Code{RevisionID: "rev-" + name},
|
||||
}}, actAccount)
|
||||
require.NoError(t, err)
|
||||
return resp.Revision
|
||||
}
|
||||
|
||||
func get(t *testing.T, svc *function.MetaService, name string) *function.GetResponse {
|
||||
t.Helper()
|
||||
resp, err := svc.Get(t.Context(), &function.GetRequest{Region: actRegion, FunctionName: name}, actAccount)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func storeCode(t *testing.T, svc *function.MetaService, name string, rev uint64) {
|
||||
t.Helper()
|
||||
_, err := svc.CodeStored(t.Context(), &function.CodeStoredRequest{Region: actRegion, FunctionName: name, Revision: rev, SHA256: actDigest, Size: 9}, actAccount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestActivation_WithoutReadinessNothingLeavesPending(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
svc.RunActivation(ctx, time.Millisecond)
|
||||
|
||||
got := get(t, svc, "hello").Record
|
||||
assert.Equal(t, function.StatePending, got.State)
|
||||
assert.Equal(t, function.ReasonCreating, got.StateReason)
|
||||
}
|
||||
|
||||
func TestActivation_UnmetConditionStaysPendingWithItsReason(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
svc.SetReadiness(func(context.Context, function.Record) error { return errors.New("The node is not serving.") })
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
first := get(t, svc, "hello")
|
||||
assert.Equal(t, function.StatePending, first.Record.State)
|
||||
assert.Equal(t, function.ReasonCodeCreating, first.Record.StateReasonCode)
|
||||
assert.Equal(t, "The node is not serving.", first.Record.StateReason)
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
assert.Equal(t, first.Revision, get(t, svc, "hello").Revision, "an unchanged reason is not rewritten")
|
||||
}
|
||||
|
||||
func TestActivation_ReadyStoredFunctionBecomesActive(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
storingRev := create(t, svc, "storing")
|
||||
var seen []string
|
||||
svc.SetReadiness(func(_ context.Context, rec function.Record) error {
|
||||
seen = append(seen, rec.FunctionName)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
|
||||
got := get(t, svc, "hello").Record
|
||||
assert.Equal(t, function.StateActive, got.State)
|
||||
assert.Empty(t, got.StateReason)
|
||||
assert.Empty(t, got.StateReasonCode)
|
||||
assert.Equal(t, function.CodeStored, got.Code.State)
|
||||
storing := get(t, svc, "storing")
|
||||
assert.Equal(t, function.StatePending, storing.Record.State, "a function still storing its code is never activated")
|
||||
assert.Equal(t, storingRev, storing.Revision)
|
||||
assert.Equal(t, []string{"hello"}, seen)
|
||||
}
|
||||
|
||||
func TestActivation_CodeStoredWakesTheLoop(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "first", create(t, svc, "first"))
|
||||
// Readiness is enabled after first is stored, so no wake is buffered and
|
||||
// only the start pass can activate first.
|
||||
svc.SetReadiness(func(context.Context, function.Record) error { return nil })
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); svc.RunActivation(ctx, time.Hour) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
require.Eventually(t, func() bool { return get(t, svc, "first").Record.State == function.StateActive },
|
||||
10*time.Second, 20*time.Millisecond, "the start pass activates what was already stored")
|
||||
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
|
||||
require.Eventually(t, func() bool { return get(t, svc, "hello").Record.State == function.StateActive },
|
||||
10*time.Second, 20*time.Millisecond, "after the start pass, only the code-stored wake can activate within the hour")
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type MetaService struct {
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]jetstream.KeyValue
|
||||
ready Readiness
|
||||
nudge chan struct{}
|
||||
}
|
||||
|
||||
// NewMetaService builds the service over a JetStream handle.
|
||||
@@ -306,6 +308,7 @@ func (s *MetaService) CodeStored(ctx context.Context, req *CodeStoredRequest, ac
|
||||
return nil, fmt.Errorf("lambda: update function record: %w", err)
|
||||
}
|
||||
attach(ctx, kv, up, upRev, rev)
|
||||
s.wake()
|
||||
return &CodeStoredResponse{Record: rec, Revision: rev}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ func (s *Service) DeleteDBInstance(ctx context.Context, input *rds.DeleteDBInsta
|
||||
"DB instance %s is already being deleted with %s; a retry must repeat that choice", id, snapshotChoice(rec.FinalSnapshotIdentifier))
|
||||
}
|
||||
|
||||
// The handler that accepted the delete tears it down, and the reconciler
|
||||
// resumes one whose handler is gone, so a repeat only reports the state.
|
||||
if rec.Status == StatusDeleting {
|
||||
return &rds.DeleteDBInstanceOutput{DBInstance: s.projectDBInstance(rec)}, nil
|
||||
}
|
||||
|
||||
// Reserve the final snapshot before the instance enters deleting. A collision
|
||||
// therefore fails while the VM and data volume are still intact, and a crash
|
||||
// afterwards leaves a creating record the reconciler can resolve.
|
||||
@@ -74,29 +80,29 @@ func (s *Service) DeleteDBInstance(ctx context.Context, input *rds.DeleteDBInsta
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A repeat of a delete already under way re-runs the teardown rather than
|
||||
// being rejected: the first call may have died partway through it.
|
||||
if rec.Status != StatusDeleting {
|
||||
now := time.Now().UTC()
|
||||
rec.Status = StatusDeleting
|
||||
rec.FailureReason = ""
|
||||
rec.FinalSnapshotIdentifier = finalSnapshot
|
||||
rec.TransitionStartedAt = &now
|
||||
rec.UpdatedAt = now
|
||||
if err := updateJSON(ctx, kv, DBInstanceKey(id), rev, rec); err != nil {
|
||||
if reservation.creator {
|
||||
s.rollbackFinalSnapshotReservation(ctx, kv, finalSnapshot, reservation.revision)
|
||||
}
|
||||
if errors.Is(err, kvstore.ErrConflict) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorDBInstanceInvalidState,
|
||||
"DB instance %s changed state concurrently; retry the delete", id)
|
||||
}
|
||||
return nil, err
|
||||
now := time.Now().UTC()
|
||||
rec.Status = StatusDeleting
|
||||
rec.FailureReason = ""
|
||||
rec.FinalSnapshotIdentifier = finalSnapshot
|
||||
rec.TransitionStartedAt = &now
|
||||
rec.UpdatedAt = now
|
||||
if err := updateJSON(ctx, kv, DBInstanceKey(id), rev, rec); err != nil {
|
||||
if reservation.creator {
|
||||
s.rollbackFinalSnapshotReservation(ctx, kv, finalSnapshot, reservation.revision)
|
||||
}
|
||||
s.RecordEvent(ctx, accountID, EventSourceTypeDBInstance, id, "DB instance deleted.", EventCategoryDeletion)
|
||||
if errors.Is(err, kvstore.ErrConflict) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorDBInstanceInvalidState,
|
||||
"DB instance %s changed state concurrently; retry the delete", id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.RecordEvent(ctx, accountID, EventSourceTypeDBInstance, id, "DB instance deleted.", EventCategoryDeletion)
|
||||
|
||||
if err := s.teardownDBInstance(ctx, kv, accountID, rec, reservation.creator); err != nil {
|
||||
// Ends exactly when the reconciler's guard does, so the teardown is this
|
||||
// handler's alone until the reconciler may resume it.
|
||||
teardownCtx, cancel := context.WithDeadline(ctx, deleteClaimEnds(rec))
|
||||
defer cancel()
|
||||
if err := s.teardownDBInstance(teardownCtx, kv, accountID, rec, reservation.creator); err != nil {
|
||||
// The reconciler finishes the delete once the modify lets go.
|
||||
if errors.Is(err, errModifyInProgress) {
|
||||
return &rds.DeleteDBInstanceOutput{DBInstance: s.projectDBInstance(rec)}, nil
|
||||
@@ -202,6 +208,11 @@ func (s *Service) teardownDBInstance(ctx context.Context, kv *kvstore.Bucket, ac
|
||||
s.stopEngineOrRecordFallback(ctx, accountID, rec, "deleting")
|
||||
}
|
||||
|
||||
// The engine stop swallows a deadline, and a handler past its claim must not
|
||||
// start a terminate the reconciler may already be running.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.terminateInstanceVM(ctx, rec.InstanceID, rec.DataVolumeID); err != nil {
|
||||
return fmt.Errorf("rds: terminate the VM behind %s: %w", rec.DBInstanceIdentifier, err)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/rds"
|
||||
@@ -176,13 +178,12 @@ func TestDeleteDBInstance_ReservesTheFinalSnapshotBeforeCuttingIt(t *testing.T)
|
||||
}
|
||||
|
||||
// A dead worker can leave the EC2 snapshot cut while its RDS record still says
|
||||
// creating. A retry adopts that exact account-scoped snapshot instead of
|
||||
// cutting another one and permanently pinning the source volume twice.
|
||||
func TestDeleteDBInstance_AdoptsAnInterruptedFinalSnapshot(t *testing.T) {
|
||||
// creating. The resumed teardown adopts that exact account-scoped snapshot
|
||||
// instead of cutting another one and permanently pinning the source volume twice.
|
||||
func TestReconciler_AdoptsAnInterruptedFinalSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
rec := availableRecord()
|
||||
rec.Status = StatusDeleting
|
||||
rec := abandonedDelete(availableRecord())
|
||||
rec.FinalSnapshotIdentifier = "orders-db-final"
|
||||
seedInstance(t, h.svc, rec)
|
||||
|
||||
@@ -201,13 +202,9 @@ func TestDeleteDBInstance_AdoptsAnInterruptedFinalSnapshot(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err = h.svc.DeleteDBInstance(t.Context(), &rds.DeleteDBInstanceInput{
|
||||
DBInstanceIdentifier: aws.String(testDBID),
|
||||
FinalDBSnapshotIdentifier: aws.String(rec.FinalSnapshotIdentifier),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, onePass(t, NewReconciler(h.svc, "node-a")))
|
||||
|
||||
assert.Empty(t, h.snaps.created, "the retry must adopt the cut snapshot, not create another")
|
||||
assert.Empty(t, h.snaps.created, "the resumed teardown must adopt the cut snapshot, not create another")
|
||||
stored, found := h.snapshotRecord(t, rec.FinalSnapshotIdentifier)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, SnapshotStatusAvailable, stored.Status)
|
||||
@@ -325,26 +322,18 @@ func TestDeleteDBInstance_RetainsTheVolumeWhenTheVolumeStoreReportsItInUse(t *te
|
||||
|
||||
// A teardown that died partway through is replayed, so every step has to treat
|
||||
// a resource that is already gone as work it has done.
|
||||
func TestDeleteDBInstance_IsIdempotentAcrossARetriedTeardown(t *testing.T) {
|
||||
func TestReconciler_IsIdempotentAcrossARetriedTeardown(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
rec := availableRecord()
|
||||
// The shape a delete leaves behind when its caller died mid-teardown.
|
||||
rec.Status = StatusDeleting
|
||||
rec := abandonedDelete(availableRecord())
|
||||
rec.FinalSnapshotIdentifier = "orders-db-final"
|
||||
seedInstance(t, h.svc, rec)
|
||||
|
||||
input := &rds.DeleteDBInstanceInput{
|
||||
DBInstanceIdentifier: aws.String(testDBID),
|
||||
FinalDBSnapshotIdentifier: aws.String("orders-db-final"),
|
||||
}
|
||||
_, err := h.svc.DeleteDBInstance(t.Context(), input, testAccountID)
|
||||
require.NoError(t, err)
|
||||
reconciler := NewReconciler(h.svc, "node-a")
|
||||
require.NoError(t, onePass(t, reconciler))
|
||||
|
||||
// The record left behind by the interrupted attempt, replayed.
|
||||
seedInstance(t, h.svc, rec)
|
||||
_, err = h.svc.DeleteDBInstance(t.Context(), input, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, onePass(t, reconciler))
|
||||
|
||||
assert.Len(t, h.snaps.created, 1, "a replayed teardown must not take a second final snapshot")
|
||||
assert.False(t, h.recordExists(t, testDBID))
|
||||
@@ -355,9 +344,7 @@ func TestDeleteDBInstance_IsIdempotentAcrossARetriedTeardown(t *testing.T) {
|
||||
func TestReconciler_ResumesAnInterruptedDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
rec := availableRecord()
|
||||
rec.Status = StatusDeleting
|
||||
seedInstance(t, h.svc, rec)
|
||||
seedInstance(t, h.svc, abandonedDelete(availableRecord()))
|
||||
|
||||
reconciler := NewReconciler(h.svc, "node-a")
|
||||
require.NoError(t, onePass(t, reconciler))
|
||||
@@ -366,6 +353,87 @@ func TestReconciler_ResumesAnInterruptedDelete(t *testing.T) {
|
||||
assert.False(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// A second teardown racing the handler's sees the VM mid-shutdown as gone and
|
||||
// fails on the still-attached volume, so the handler's delete is left to it.
|
||||
func TestReconciler_LeavesADeleteItsHandlerMayStillBeRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
rec := availableRecord()
|
||||
started := time.Now().UTC()
|
||||
rec.Status = StatusDeleting
|
||||
rec.TransitionStartedAt = &started
|
||||
seedInstance(t, h.svc, rec)
|
||||
|
||||
require.NoError(t, onePass(t, NewReconciler(h.svc, "node-a")))
|
||||
|
||||
assert.Empty(t, h.launcher.terminated)
|
||||
assert.True(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// A repeat must not start a teardown of its own alongside the one under way;
|
||||
// the reconciler is what resumes an abandoned one.
|
||||
func TestDeleteDBInstance_ARepeatStartsNoTeardown(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
seedInstance(t, h.svc, abandonedDelete(availableRecord()))
|
||||
|
||||
out, err := h.svc.DeleteDBInstance(t.Context(), skipFinalSnapshot(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(StatusDeleting), aws.StringValue(out.DBInstance.DBInstanceStatus))
|
||||
assert.Empty(t, h.launcher.terminated)
|
||||
assert.True(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// The reconciler resumes a delete from the persisted stamp, so the handler's
|
||||
// teardown must stop at that same instant, however long the acceptance took.
|
||||
func TestDeleteDBInstance_TeardownEndsWhenTheClaimDoes(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
seedInstance(t, h.svc, availableRecord())
|
||||
var claimEnds time.Time
|
||||
h.snaps.beforeCreate = func() { stored := h.record(t); claimEnds = deleteClaimEnds(&stored) }
|
||||
|
||||
_, err := h.svc.DeleteDBInstance(t.Context(), &rds.DeleteDBInstanceInput{
|
||||
DBInstanceIdentifier: aws.String(testDBID),
|
||||
FinalDBSnapshotIdentifier: aws.String("orders-db-final"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(t, claimEnds.IsZero())
|
||||
assert.True(t, h.snaps.deadline.Equal(claimEnds), "deadline %s, claim ends %s", h.snaps.deadline, claimEnds)
|
||||
}
|
||||
|
||||
// A graceful stop that runs into the claim's end is swallowed, so the teardown
|
||||
// itself has to notice and leave the terminate to the reconciler.
|
||||
func TestTeardownDBInstance_StartsNoTerminatePastItsDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
h.agent.silenceType(CommandStopEngine)
|
||||
rec := availableRecord()
|
||||
rec.Status = StatusDeleting
|
||||
rec.FinalSnapshotIdentifier = "orders-db-final"
|
||||
seedInstance(t, h.svc, rec)
|
||||
kv, err := h.svc.bucket(t.Context(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
err = h.svc.teardownDBInstance(ctx, kv, testAccountID, &rec, true)
|
||||
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.Empty(t, h.launcher.terminated)
|
||||
assert.True(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// The shape a delete leaves once its handler's request budget has run out.
|
||||
func abandonedDelete(rec DBInstanceRecord) DBInstanceRecord {
|
||||
started := time.Now().UTC().Add(-deleteTimeout - time.Minute)
|
||||
rec.Status = StatusDeleting
|
||||
rec.TransitionStartedAt = &started
|
||||
return rec
|
||||
}
|
||||
|
||||
// A stop whose caller died leaves the VM possibly still running, so the stop is
|
||||
// re-issued rather than assumed.
|
||||
func TestReconciler_ResumesAnInterruptedStop(t *testing.T) {
|
||||
|
||||
@@ -117,7 +117,9 @@ func (f *fakeInstanceCommander) DetachVolumeFromStopped(_ context.Context, insta
|
||||
// DescribeSnapshots reports against the data volume, which is what decides
|
||||
// between deleting the volume and retaining it.
|
||||
type fakeSnapshots struct {
|
||||
created []*ec2.CreateSnapshotInput
|
||||
created []*ec2.CreateSnapshotInput
|
||||
// The context deadline the last CreateSnapshot ran under.
|
||||
deadline time.Time
|
||||
holding []string
|
||||
deleted []string
|
||||
createErr error
|
||||
@@ -132,7 +134,8 @@ type fakeSnapshots struct {
|
||||
|
||||
var _ snapshotProvider = (*fakeSnapshots)(nil)
|
||||
|
||||
func (f *fakeSnapshots) CreateSnapshot(_ context.Context, input *ec2.CreateSnapshotInput, _ string) (*ec2.Snapshot, error) {
|
||||
func (f *fakeSnapshots) CreateSnapshot(ctx context.Context, input *ec2.CreateSnapshotInput, _ string) (*ec2.Snapshot, error) {
|
||||
f.deadline, _ = ctx.Deadline()
|
||||
if f.beforeCreate != nil {
|
||||
f.beforeCreate()
|
||||
}
|
||||
|
||||
@@ -610,6 +610,11 @@ func (r *Reconciler) reconcileStopping(ctx context.Context, kv *kvstore.Bucket,
|
||||
// did; only a teardown still stuck past the bound is called failed, which is
|
||||
// what lets the customer retry the delete.
|
||||
func (r *Reconciler) reconcileDeleting(ctx context.Context, kv *kvstore.Bucket, rev uint64, accountID string, rec *DBInstanceRecord) error {
|
||||
// The handler that accepted the delete tears it down until its claim ends; a
|
||||
// second teardown alongside it sees the VM mid-shutdown as gone.
|
||||
if time.Now().Before(deleteClaimEnds(rec)) {
|
||||
return nil
|
||||
}
|
||||
err := r.svc.teardownDBInstance(ctx, kv, accountID, rec, false)
|
||||
if err == nil || errors.Is(err, errFinalSnapshotInProgress) || errors.Is(err, errModifyInProgress) {
|
||||
return nil
|
||||
@@ -795,6 +800,12 @@ func (s *Service) findEC2SnapshotFor(ctx context.Context, accountID, dbSnapshotI
|
||||
// When the transition began. A record written by an older control plane carries
|
||||
// no stamp, so its last write stands in — it is never earlier than the
|
||||
// transition, so the bound cannot be under-counted.
|
||||
// When the handler that accepted a delete gives up its teardown, measured from
|
||||
// the persisted stamp so the handler and the reconciler agree on it.
|
||||
func deleteClaimEnds(rec *DBInstanceRecord) time.Time {
|
||||
return transitionStarted(rec).Add(deleteTimeout)
|
||||
}
|
||||
|
||||
func transitionStarted(rec *DBInstanceRecord) time.Time {
|
||||
if rec.TransitionStartedAt != nil {
|
||||
return *rec.TransitionStartedAt
|
||||
|
||||
@@ -151,6 +151,9 @@ type ControlServer struct {
|
||||
// IOTimeout bounds reading the request and writing the reply.
|
||||
IOTimeout time.Duration
|
||||
Handle func(ctx context.Context, req []byte, cred Credential) Reply
|
||||
// AcceptStopped, if set, runs once Serve stops accepting, before it waits
|
||||
// for the connections it started.
|
||||
AcceptStopped func()
|
||||
}
|
||||
|
||||
// Serve accepts until ctx ends or the listener fails, then waits for every
|
||||
@@ -164,6 +167,9 @@ func (s *ControlServer) Serve(ctx context.Context, ln *net.UnixListener) error {
|
||||
defer func() { _ = ln.Close() }()
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
if s.AcceptStopped != nil {
|
||||
defer s.AcceptStopped()
|
||||
}
|
||||
slots := make(chan struct{}, MaxAdmitted)
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -462,3 +462,45 @@ func TestControl_MixedProtocolNamesNeverConnect(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestControl_AcceptStoppedRunsBeforeTheDrain(t *testing.T) {
|
||||
name := controlName(t)
|
||||
ln, err := ListenControl(name)
|
||||
require.NoError(t, err)
|
||||
inHandle, release := make(chan struct{}), make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
srv := &ControlServer{GatewayUID: os.Getuid(), RequestLimit: testRequestLimit, ReplyLimit: 4096, IOTimeout: 5 * time.Second,
|
||||
Handle: func(context.Context, []byte, Credential) Reply {
|
||||
close(inHandle)
|
||||
<-release
|
||||
return Reply{Outcome: OutcomeDone}
|
||||
},
|
||||
AcceptStopped: func() { close(stopped) }}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- srv.Serve(context.Background(), ln) }()
|
||||
go func() { _, _ = client(name, os.Getuid()).Exchange(context.Background(), []byte("{}"), testCred, soon()) }()
|
||||
select {
|
||||
case <-inHandle:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the request never reached Handle")
|
||||
}
|
||||
|
||||
require.NoError(t, ln.Close(), "the listener fails under an outstanding invocation")
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("AcceptStopped did not run while the invocation was outstanding")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Serve returned before its connection finished: %v", err)
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-done:
|
||||
require.Error(t, err, "a listener failure is reported")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Serve did not return after its connection finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,17 @@ type Daemon struct {
|
||||
// InitializerSHA256 the digest it must have.
|
||||
Initializer string
|
||||
InitializerSHA256 string
|
||||
// Firecracker, when set, is the Firecracker binary each attempt boots
|
||||
// under instead of QEMU.
|
||||
Firecracker string
|
||||
|
||||
busy atomic.Bool
|
||||
busy atomic.Bool
|
||||
serving atomic.Bool
|
||||
}
|
||||
|
||||
// Serving reports whether the control socket is accepting invocations.
|
||||
func (d *Daemon) Serving() bool { return d.serving.Load() }
|
||||
|
||||
// unresolved names an attempt whose marker is still on disk. A marker goes
|
||||
// only once the attempt's VM stop is proven and everything it held is
|
||||
// released, so it outlives a daemon restart; nothing resolves it on its own.
|
||||
@@ -108,6 +115,12 @@ func (d *Daemon) unresolved() (string, error) {
|
||||
return strings.TrimSuffix(filepath.Base(markers[0]), markerSuffix), nil
|
||||
}
|
||||
|
||||
// validHandler is the handler a guest run accepts: 1..128 bytes, no
|
||||
// whitespace or NUL.
|
||||
func validHandler(h string) bool {
|
||||
return h != "" && len(h) <= 128 && strings.IndexFunc(h, unicode.IsSpace) < 0 && !strings.ContainsRune(h, 0)
|
||||
}
|
||||
|
||||
// controlIOTimeout bounds reading a request from, and writing a reply to, the
|
||||
// gateway on the control socket.
|
||||
const controlIOTimeout = 30 * time.Second
|
||||
@@ -119,14 +132,24 @@ func (d *Daemon) ServeControl(ctx context.Context, name string, gatewayUID int)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &ControlServer{GatewayUID: gatewayUID, RequestLimit: MaxFrameBytes, ReplyLimit: MaxFrameBytes,
|
||||
IOTimeout: controlIOTimeout, Handle: d.Handle}
|
||||
d.serve(ctx, ln, d.controlServer(gatewayUID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// controlServer answers the gateway with d.Handle, and stops d reporting
|
||||
// serving as soon as it stops accepting, before its connections drain.
|
||||
func (d *Daemon) controlServer(gatewayUID int) *ControlServer {
|
||||
return &ControlServer{GatewayUID: gatewayUID, RequestLimit: MaxFrameBytes, ReplyLimit: MaxFrameBytes,
|
||||
IOTimeout: controlIOTimeout, Handle: d.Handle, AcceptStopped: func() { d.serving.Store(false) }}
|
||||
}
|
||||
|
||||
func (d *Daemon) serve(ctx context.Context, ln *net.UnixListener, srv *ControlServer) {
|
||||
d.serving.Store(true)
|
||||
go func() {
|
||||
if err := srv.Serve(ctx, ln); err != nil {
|
||||
slog.Error("Lambda test link: control socket stopped; not serving new invocations", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle runs one request under its grant, giving the guest cred in its first
|
||||
@@ -293,8 +316,7 @@ func CheckRequest(req Request) error {
|
||||
if req.RequestID == "" || req.RevisionID == "" {
|
||||
return errors.New("request and revision IDs are required")
|
||||
}
|
||||
if req.Handler == "" || len(req.Handler) > 128 || strings.IndexFunc(req.Handler, unicode.IsSpace) >= 0 ||
|
||||
strings.ContainsRune(req.Handler, 0) {
|
||||
if !validHandler(req.Handler) {
|
||||
return errors.New("handler must be 1..128 characters with no whitespace")
|
||||
}
|
||||
if req.TimeoutMs <= 0 || req.TimeoutMs > maxTimeoutMs {
|
||||
@@ -404,24 +426,50 @@ func (d *Daemon) run(ctx context.Context, req Request, code []byte, lib *os.File
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
|
||||
cmd, err := cfg.Execute()
|
||||
if err != nil {
|
||||
return refused("VM command build failed"), d.release(a), marker
|
||||
}
|
||||
// A launch delayed past the fixed deadline is refused before QEMU starts.
|
||||
// A launch delayed past the fixed deadline is refused before the VM starts.
|
||||
if expired(hardDeadline) {
|
||||
return refused(ErrDeadlineExpired.Error()), d.release(a), marker
|
||||
}
|
||||
hk, err := armLaunch(cmd, d.Image, lib, hardDeadline)
|
||||
if err != nil {
|
||||
return refused("hard-kill setup failed"), d.release(a), marker
|
||||
var cmd *exec.Cmd
|
||||
var hk *hardKill
|
||||
var fcAck *net.UnixConn
|
||||
fc := &vmHandle{fd: -1}
|
||||
if d.Firecracker == "" {
|
||||
if cmd, err = cfg.Execute(); err != nil {
|
||||
return refused("VM command build failed"), d.release(a), marker
|
||||
}
|
||||
if hk, err = armLaunch(cmd, d.Image, lib, hardDeadline); err != nil {
|
||||
return refused("hard-kill setup failed"), d.release(a), marker
|
||||
}
|
||||
defer hk.close()
|
||||
} else {
|
||||
var child *os.File
|
||||
if fcAck, child, err = ackPair(); err != nil {
|
||||
return refused("hard-kill setup failed"), d.release(a), marker
|
||||
}
|
||||
defer func() { _ = fcAck.Close() }()
|
||||
cmd, err = d.supervisorCommand(filepath.Join(a.dir, fcConfigFile), hardDeadline, child)
|
||||
defer func() { _ = child.Close() }()
|
||||
if err != nil {
|
||||
return refused("VM command build failed"), d.release(a), marker
|
||||
}
|
||||
}
|
||||
defer hk.close()
|
||||
var result Reply
|
||||
unrecorded := false
|
||||
out, runErr := attempt.Run(ctx, cmd, timeoutFor(req), func(sctx context.Context, pidfd int) error {
|
||||
vmPidfd, vmPID := pidfd, cmd.Process.Pid
|
||||
if fcAck != nil {
|
||||
defer fc.callbackDone()
|
||||
// Firecracker is the VM; its supervisor acks only once both kernel kills are armed.
|
||||
fd, pid, err := awaitFirecracker(sctx, fcAck, cmd.Process.Pid, armWait)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fc.set(fd)
|
||||
vmPidfd, vmPID = fd, pid
|
||||
}
|
||||
// The owner holds the launch identity before the guest is sent anything.
|
||||
l, err := launchIdentity(pidfd, cmd.Process.Pid, a.tap)
|
||||
l, err := launchIdentity(vmPidfd, vmPID, a.tap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -430,13 +478,20 @@ func (d *Daemon) run(ctx context.Context, req Request, code []byte, lib *os.File
|
||||
return fmt.Errorf("%w: %w", ErrLaunchNotRecorded, err)
|
||||
}
|
||||
// No frame reaches the guest until QEMU proves its own kill timer is armed.
|
||||
if err := hk.await(sctx, cmd.Process.Pid, armWait); err != nil {
|
||||
return err
|
||||
if hk != nil {
|
||||
if err := hk.await(sctx, cmd.Process.Pid, armWait); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r, err := d.session(sctx, req, gen, ln, a.dir, hardDeadline, d.Grants.Accept)
|
||||
result = r
|
||||
return err
|
||||
})
|
||||
if fcAck != nil {
|
||||
// Always settled, so a received pidfd is closed or handed to the callback.
|
||||
fcGone := settleFirecracker(out.SessionDone, fc, cmd.Process != nil)
|
||||
out.Stopped = out.Stopped && fcGone
|
||||
}
|
||||
if !out.Stopped {
|
||||
slog.Error("Lambda test link: VM stop not proven; marker, tap, address and files held",
|
||||
"attempt", req.AttemptID, "tap", tapName, "err", runErr)
|
||||
@@ -527,6 +582,22 @@ func (d *Daemon) prepare(req Request, code []byte, dir, tap, mac, ip string) (*v
|
||||
entries["test-package-sha256"] = []byte(req.CodeSHA256)
|
||||
entries["test-grant"] = grantEntry(req.Grant)
|
||||
entries["test-handler"] = []byte(req.Handler)
|
||||
netcfgBytes := fmt.Appendf(nil, "NIC0_MAC=%s\nNIC0_CIDR=%s/24\nNIC0_DEFAULT=0\n", mac, ip)
|
||||
if d.Firecracker != "" {
|
||||
archive := make(map[string][]byte, len(entries)+1)
|
||||
for name, value := range entries {
|
||||
archive[lambdav1.FwCfgPrefix+name] = value
|
||||
}
|
||||
archive[fwCfgNetcfg] = netcfgBytes
|
||||
cfgDisk := filepath.Join(dir, configArchive)
|
||||
if err := writeConfigArchive(cfgDisk, archive); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := writeFirecrackerConfig(filepath.Join(dir, fcConfigFile), diskPath, cfgDisk, tap, mac, int(max(req.MemoryMiB, 128))); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return nil, gen, ln, nil
|
||||
}
|
||||
var fwCfg []vm.FwCfgEntry
|
||||
for name, value := range entries {
|
||||
path := filepath.Join(dir, "fwcfg-"+name)
|
||||
@@ -536,7 +607,7 @@ func (d *Daemon) prepare(req Request, code []byte, dir, tap, mac, ip string) (*v
|
||||
fwCfg = append(fwCfg, vm.FwCfgEntry{Name: lambdav1.FwCfgPrefix + name, File: path})
|
||||
}
|
||||
netcfg := filepath.Join(dir, "netcfg")
|
||||
if err := os.WriteFile(netcfg, fmt.Appendf(nil, "NIC0_MAC=%s\nNIC0_CIDR=%s/24\nNIC0_DEFAULT=0\n", mac, ip), 0o600); err != nil {
|
||||
if err := os.WriteFile(netcfg, netcfgBytes, 0o600); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
fwCfg = append(fwCfg, vm.FwCfgEntry{Name: fwCfgNetcfg, File: netcfg})
|
||||
@@ -588,6 +659,7 @@ func (d *Daemon) session(ctx context.Context, req Request, gen *guestconn.Genera
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = os.Remove(filepath.Join(dir, "fwcfg-"+lambdav1.EntryGuestKey))
|
||||
_ = os.Remove(filepath.Join(dir, configArchive))
|
||||
|
||||
// The function timeout runs on the host from dispatch; the boot allowance
|
||||
// covers startup only, so a guest that stops answering is cut off here.
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// firecrackerArg0 is the argv[0] that turns the spx binary into the
|
||||
// Firecracker supervisor, so the supervisor is the already-pinned binary.
|
||||
const firecrackerArg0 = "spx-lambda-firecracker"
|
||||
|
||||
// The supervisor inherits the sealed kernel and initramfs as fds 3 and 4,
|
||||
// which it passes on to Firecracker unchanged, and its ack socket as fd 5.
|
||||
const (
|
||||
fcKernelFD = 3
|
||||
fcInitrdFD = 4
|
||||
fcAckFD = 5
|
||||
fcAck = "armed"
|
||||
)
|
||||
|
||||
// firecrackerCmdline is the guest kernel command line; reboot=k lets a guest
|
||||
// reboot end Firecracker, and Firecracker adds the virtio-mmio devices itself.
|
||||
const firecrackerCmdline = "console=ttyS0 quiet loglevel=3 reboot=k panic=1 pci=off"
|
||||
|
||||
const (
|
||||
configArchive = "config.tar"
|
||||
fcConfigFile = "firecracker.json"
|
||||
// fcStopWait bounds proving Firecracker gone after its supervisor stopped.
|
||||
fcStopWait = 5 * time.Second
|
||||
)
|
||||
|
||||
func init() {
|
||||
if filepath.Base(os.Args[0]) == firecrackerArg0 {
|
||||
os.Exit(supervise(os.Args[1:]))
|
||||
}
|
||||
}
|
||||
|
||||
// supervise arms a CLOCK_BOOTTIME SIGKILL on itself at the absolute deadline,
|
||||
// then starts Firecracker with SIGKILL as its parent-death signal, so the
|
||||
// kernel ends both at the deadline even if this process and the daemon stop.
|
||||
func supervise(args []string) int {
|
||||
if len(args) != 3 {
|
||||
return 2
|
||||
}
|
||||
deadline, err := strconv.ParseInt(args[2], 10, 64)
|
||||
if err != nil {
|
||||
return 2
|
||||
}
|
||||
unix.CloseOnExec(fcAckFD)
|
||||
// The parent-death signal follows the forking thread, which never exits.
|
||||
runtime.LockOSThread()
|
||||
if err := armBoottimeKill(time.Duration(deadline)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "firecracker supervisor:", err)
|
||||
return 3
|
||||
}
|
||||
pidfd := -1
|
||||
cmd := exec.Command(args[0], "--no-api", "--config-file", args[1], "--level", "Warning")
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
cmd.ExtraFiles = []*os.File{os.NewFile(fcKernelFD, "kernel"), os.NewFile(fcInitrdFD, "initrd")}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL, PidFD: &pidfd}
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "firecracker supervisor: start:", err)
|
||||
return 4
|
||||
}
|
||||
if err := unix.Sendmsg(fcAckFD, []byte(fcAck), unix.UnixRights(pidfd), nil, 0); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return 5
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode()
|
||||
}
|
||||
return 6
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// sigevent is the kernel's struct sigevent on 64-bit Linux.
|
||||
type sigevent struct {
|
||||
value uintptr
|
||||
signo int32
|
||||
notify int32
|
||||
_ [48]byte
|
||||
}
|
||||
|
||||
const (
|
||||
sigevSignal = 0
|
||||
timerAbstime = 1
|
||||
)
|
||||
|
||||
// armBoottimeKill arms a POSIX timer that delivers SIGKILL to this process at
|
||||
// the absolute CLOCK_BOOTTIME deadline; a passed deadline fires at once.
|
||||
func armBoottimeKill(deadline time.Duration) error {
|
||||
ev := sigevent{signo: int32(unix.SIGKILL), notify: sigevSignal}
|
||||
var id int32
|
||||
if _, _, e := unix.Syscall(unix.SYS_TIMER_CREATE, unix.CLOCK_BOOTTIME,
|
||||
uintptr(unsafe.Pointer(&ev)), uintptr(unsafe.Pointer(&id))); e != 0 {
|
||||
return fmt.Errorf("timer_create: %w", e)
|
||||
}
|
||||
spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(deadline))}
|
||||
if _, _, e := unix.Syscall6(unix.SYS_TIMER_SETTIME, uintptr(id), timerAbstime,
|
||||
uintptr(unsafe.Pointer(&spec)), 0, 0, 0); e != 0 {
|
||||
return fmt.Errorf("timer_settime: %w", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// supervisorCommand builds the supervisor for one attempt: the spx binary
|
||||
// under firecrackerArg0, the sealed kernel and initramfs, and the ack socket.
|
||||
func (d *Daemon) supervisorCommand(configPath string, deadline time.Duration, ack *os.File) (*exec.Cmd, error) {
|
||||
if d.Image == nil || d.Image.Kernel == nil || d.Image.Initrd == nil {
|
||||
return nil, errors.New("runtime image not prepared")
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(self, d.Firecracker, configPath, strconv.FormatInt(int64(deadline), 10))
|
||||
cmd.Args[0] = firecrackerArg0
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
cmd.ExtraFiles = []*os.File{d.Image.Kernel, d.Image.Initrd, ack}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// ackPair is the socket the supervisor acks on; the daemon keeps one end.
|
||||
func ackPair() (daemonEnd *net.UnixConn, child *os.File, err error) {
|
||||
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
child = os.NewFile(uintptr(fds[1]), "fc-ack")
|
||||
f := os.NewFile(uintptr(fds[0]), "fc-ack-daemon")
|
||||
c, err := net.FileConn(f)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
_ = child.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return c.(*net.UnixConn), child, nil
|
||||
}
|
||||
|
||||
// awaitFirecracker reads the supervisor's ack and the pidfd it carries, and
|
||||
// checks that pidfd names a live child of the supervisor.
|
||||
func awaitFirecracker(ctx context.Context, ack *net.UnixConn, supervisor int, wait time.Duration) (pidfd, pid int, err error) {
|
||||
deadline := time.Now().Add(wait)
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
if err := ack.SetReadDeadline(deadline); err != nil {
|
||||
return -1, 0, err
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
oob := make([]byte, unix.CmsgSpace(4))
|
||||
n, oobn, _, _, err := ack.ReadMsgUnix(buf, oob)
|
||||
if err != nil {
|
||||
return -1, 0, fmt.Errorf("%w: %w", ErrNotArmed, err)
|
||||
}
|
||||
msgs, err := unix.ParseSocketControlMessage(oob[:oobn])
|
||||
if err != nil || len(msgs) != 1 {
|
||||
return -1, 0, fmt.Errorf("%w: no pidfd in ack", ErrNotArmed)
|
||||
}
|
||||
fds, err := unix.ParseUnixRights(&msgs[0])
|
||||
if err != nil || len(fds) != 1 {
|
||||
return -1, 0, fmt.Errorf("%w: no pidfd in ack", ErrNotArmed)
|
||||
}
|
||||
pidfd = fds[0]
|
||||
unix.CloseOnExec(pidfd)
|
||||
if string(buf[:n]) != fcAck {
|
||||
_ = unix.Close(pidfd)
|
||||
return -1, 0, fmt.Errorf("%w: unexpected ack", ErrNotArmed)
|
||||
}
|
||||
if pid, err = pidfdPID(pidfd); err == nil {
|
||||
err = checkParent(pid, supervisor)
|
||||
}
|
||||
if err != nil {
|
||||
_ = unix.Close(pidfd)
|
||||
return -1, 0, fmt.Errorf("%w: %w", ErrNotArmed, err)
|
||||
}
|
||||
return pidfd, pid, nil
|
||||
}
|
||||
|
||||
// pidfdPID reads the PID a pidfd refers to from its fdinfo.
|
||||
func pidfdPID(pidfd int) (int, error) {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/self/fdinfo/%d", pidfd))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for line := range strings.SplitSeq(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "Pid:"); ok {
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, fmt.Errorf("pidfd names no live process (%q)", strings.TrimSpace(v))
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("pidfd fdinfo has no Pid")
|
||||
}
|
||||
|
||||
func checkParent(pid, parent int) error {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for line := range strings.SplitSeq(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "PPid:"); ok {
|
||||
if strings.TrimSpace(v) != strconv.Itoa(parent) {
|
||||
return fmt.Errorf("process %d is not the supervisor's child", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("process status has no PPid")
|
||||
}
|
||||
|
||||
// stopFirecracker kills Firecracker through its pidfd and reports whether its
|
||||
// exit was proven; the supervisor's own exit proves nothing about it.
|
||||
func stopFirecracker(pidfd int) bool {
|
||||
if pidfd < 0 {
|
||||
return false
|
||||
}
|
||||
_ = unix.PidfdSendSignal(pidfd, unix.SIGKILL, nil, 0)
|
||||
exited, err := pidfdExited(pidfd, fcStopWait)
|
||||
return err == nil && exited
|
||||
}
|
||||
|
||||
// settleFirecracker reports whether Firecracker is proven gone once Run has
|
||||
// returned. A supervisor that never started launched nothing; otherwise only
|
||||
// Firecracker's own pidfd proves its exit, since the supervisor's says nothing.
|
||||
func settleFirecracker(sessionDone <-chan struct{}, fc *vmHandle, supervisorStarted bool) bool {
|
||||
select {
|
||||
case <-sessionDone:
|
||||
default:
|
||||
// The callback may still receive the pidfd; it closes it, and the attempt is held.
|
||||
fc.abandon()
|
||||
return false
|
||||
}
|
||||
fd := fc.take()
|
||||
if fd < 0 {
|
||||
return !supervisorStarted
|
||||
}
|
||||
defer func() { _ = unix.Close(fd) }()
|
||||
return stopFirecracker(fd)
|
||||
}
|
||||
|
||||
// writeConfigArchive writes entries as a tar archive in the fw_cfg sysfs
|
||||
// layout, by_name/<entry>/raw, which the guest's init extracts.
|
||||
func writeConfigArchive(path string, entries map[string][]byte) error {
|
||||
names := make([]string, 0, len(entries))
|
||||
for name := range entries {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
dirs := map[string]bool{}
|
||||
for _, name := range names {
|
||||
file := "by_name/" + name + "/raw"
|
||||
var parents []string
|
||||
for dir := filepath.Dir(file); dir != "." && !dirs[dir]; dir = filepath.Dir(dir) {
|
||||
parents = append(parents, dir)
|
||||
dirs[dir] = true
|
||||
}
|
||||
for i := len(parents) - 1; i >= 0; i-- {
|
||||
if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: parents[i] + "/", Mode: 0o700}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b := entries[name]
|
||||
if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: file, Mode: 0o600, Size: int64(len(b))}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buf.Bytes(), 0o600)
|
||||
}
|
||||
|
||||
type fcConfig struct {
|
||||
BootSource struct {
|
||||
Kernel string `json:"kernel_image_path"`
|
||||
Initrd string `json:"initrd_path"`
|
||||
BootArgs string `json:"boot_args"`
|
||||
} `json:"boot-source"`
|
||||
Drives []fcDrive `json:"drives"`
|
||||
Machine struct {
|
||||
VCPUs int `json:"vcpu_count"`
|
||||
MemMiB int `json:"mem_size_mib"`
|
||||
SMT bool `json:"smt"`
|
||||
} `json:"machine-config"`
|
||||
Net []fcNet `json:"network-interfaces"`
|
||||
}
|
||||
|
||||
type fcDrive struct {
|
||||
ID string `json:"drive_id"`
|
||||
Path string `json:"path_on_host"`
|
||||
Root bool `json:"is_root_device"`
|
||||
ReadOnly bool `json:"is_read_only"`
|
||||
}
|
||||
|
||||
type fcNet struct {
|
||||
ID string `json:"iface_id"`
|
||||
MAC string `json:"guest_mac"`
|
||||
Tap string `json:"host_dev_name"`
|
||||
}
|
||||
|
||||
// writeFirecrackerConfig writes the attempt's Firecracker config: the sealed
|
||||
// kernel and initramfs, the package disk then the configuration disk, and the tap.
|
||||
func writeFirecrackerConfig(path, pkg, cfgDisk, tap, mac string, memMiB int) error {
|
||||
var c fcConfig
|
||||
c.BootSource.Kernel = fmt.Sprintf("/proc/self/fd/%d", fcKernelFD)
|
||||
c.BootSource.Initrd = fmt.Sprintf("/proc/self/fd/%d", fcInitrdFD)
|
||||
c.BootSource.BootArgs = firecrackerCmdline
|
||||
c.Drives = []fcDrive{{ID: "pkg", Path: pkg, ReadOnly: true}, {ID: "cfg", Path: cfgDisk}}
|
||||
c.Machine.VCPUs, c.Machine.MemMiB = 1, memMiB
|
||||
c.Net = []fcNet{{ID: "net0", MAC: mac, Tap: tap}}
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o600)
|
||||
}
|
||||
|
||||
// vmHandle owns Firecracker's pidfd across the session callback, which can
|
||||
// still be running when attempt.Run returns; whichever side finishes last
|
||||
// closes an abandoned fd.
|
||||
type vmHandle struct {
|
||||
mu sync.Mutex
|
||||
fd int
|
||||
finished bool
|
||||
abandoned bool
|
||||
}
|
||||
|
||||
func (h *vmHandle) set(fd int) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.fd = fd
|
||||
}
|
||||
|
||||
// callbackDone runs as the callback returns.
|
||||
func (h *vmHandle) callbackDone() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.finished = true
|
||||
h.closeIfAbandoned()
|
||||
}
|
||||
|
||||
// abandon gives up the fd while the callback may still run.
|
||||
func (h *vmHandle) abandon() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.abandoned = true
|
||||
h.closeIfAbandoned()
|
||||
}
|
||||
|
||||
func (h *vmHandle) closeIfAbandoned() {
|
||||
if h.abandoned && h.finished && h.fd >= 0 {
|
||||
_ = unix.Close(h.fd)
|
||||
h.fd = -1
|
||||
}
|
||||
}
|
||||
|
||||
// take hands the fd to teardown once the callback has returned.
|
||||
func (h *vmHandle) take() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
fd := h.fd
|
||||
h.fd = -1
|
||||
return fd
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
//test:in-package drives the unexported supervisor, ack and pidfd helpers against real processes
|
||||
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"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/attempt"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// supervised starts this test binary as the Firecracker supervisor over a
|
||||
// stand-in VMM that runs until killed, and returns the supervisor and the
|
||||
// VMM's pidfd from the ack.
|
||||
func supervised(t *testing.T, deadline time.Duration) (*exec.Cmd, int) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
vmm := filepath.Join(dir, "firecracker")
|
||||
require.NoError(t, os.WriteFile(vmm, []byte("#!/bin/sh\nexec tail -f /dev/null\n"), 0o755))
|
||||
kernel, err := os.Open(os.DevNull)
|
||||
require.NoError(t, err)
|
||||
initrd, err := os.Open(os.DevNull)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = kernel.Close(); _ = initrd.Close() })
|
||||
d := &Daemon{Firecracker: vmm, Image: &Image{Kernel: kernel, Initrd: initrd}}
|
||||
ack, child, err := ackPair()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ack.Close() })
|
||||
cmd, err := d.supervisorCommand(filepath.Join(dir, fcConfigFile), deadline, child)
|
||||
require.NoError(t, err)
|
||||
cmd.Path = os.Args[0]
|
||||
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
|
||||
require.NoError(t, cmd.Start())
|
||||
_ = child.Close()
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
|
||||
|
||||
pidfd, pid, err := awaitFirecracker(context.Background(), ack, cmd.Process.Pid, armWait)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = unix.PidfdSendSignal(pidfd, unix.SIGKILL, nil, 0); _ = unix.Close(pidfd) })
|
||||
require.NotEqual(t, cmd.Process.Pid, pid)
|
||||
return cmd, pidfd
|
||||
}
|
||||
|
||||
func boottime(t *testing.T) time.Duration {
|
||||
t.Helper()
|
||||
now, err := bootNow()
|
||||
require.NoError(t, err)
|
||||
return now
|
||||
}
|
||||
|
||||
func TestFirecrackerSupervisorKillsAtBoottimeDeadlineWhileStopped(t *testing.T) {
|
||||
cmd, pidfd := supervised(t, boottime(t)+1500*time.Millisecond)
|
||||
|
||||
// The kernel holds a SIGKILL timer on CLOCK_BOOTTIME (7), so the deadline counts suspend.
|
||||
timers, err := os.ReadFile("/proc/" + strconv.Itoa(cmd.Process.Pid) + "/timers")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(timers), "signal: 9/")
|
||||
assert.Contains(t, string(timers), "ClockID: 7")
|
||||
|
||||
// A stopped supervisor still loses the VMM at the deadline: both kills come from the kernel.
|
||||
require.NoError(t, cmd.Process.Signal(syscall.SIGSTOP))
|
||||
exited, err := pidfdExited(pidfd, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exited, "VMM outlived its deadline")
|
||||
state, err := cmd.Process.Wait()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, syscall.SIGKILL, state.Sys().(syscall.WaitStatus).Signal())
|
||||
}
|
||||
|
||||
func TestFirecrackerDiesWithItsSupervisor(t *testing.T) {
|
||||
cmd, pidfd := supervised(t, boottime(t)+time.Hour)
|
||||
exited, err := pidfdExited(pidfd, 0)
|
||||
require.NoError(t, err)
|
||||
require.False(t, exited)
|
||||
|
||||
require.NoError(t, cmd.Process.Kill())
|
||||
_, _ = cmd.Process.Wait()
|
||||
// Only the kernel's parent-death signal can end the VMM here; nothing else signals it.
|
||||
exited, err = pidfdExited(pidfd, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exited, "VMM outlived its supervisor")
|
||||
}
|
||||
|
||||
// A supervisor that never started launched nothing, so the attempt is
|
||||
// released; one that started without handing over a pidfd may have started
|
||||
// Firecracker, so the attempt is held.
|
||||
func TestSettleFirecracker_OnlyANeverStartedSupervisorIsProvenStopped(t *testing.T) {
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cases := map[string]struct {
|
||||
ctx context.Context
|
||||
cmd func() *exec.Cmd
|
||||
want bool
|
||||
}{
|
||||
"cancelled before Start": {cancelled, func() *exec.Cmd { return exec.Command("/bin/true") }, true},
|
||||
"failed Start": {context.Background(), func() *exec.Cmd { return exec.Command(filepath.Join(t.TempDir(), "missing")) }, true},
|
||||
"started, no pidfd": {context.Background(), func() *exec.Cmd { return exec.Command("/bin/true") }, false},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := tc.cmd()
|
||||
fc := &vmHandle{fd: -1}
|
||||
out, _ := attempt.Run(tc.ctx, cmd, 10*time.Second, func(context.Context, int) error {
|
||||
fc.callbackDone()
|
||||
return errors.New("supervisor exited before its ack")
|
||||
})
|
||||
require.True(t, out.Stopped, "the supervisor itself is proven stopped")
|
||||
<-out.SessionDone
|
||||
assert.Equal(t, tc.want, settleFirecracker(out.SessionDone, fc, cmd.Process != nil))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirecrackerAckRequiresTheSupervisorsChild(t *testing.T) {
|
||||
ack, child, err := ackPair()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = ack.Close() }()
|
||||
self, err := unix.PidfdOpen(os.Getpid(), 0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, unix.Sendmsg(int(child.Fd()), []byte(fcAck), unix.UnixRights(self), nil, 0))
|
||||
_ = unix.Close(self)
|
||||
_ = child.Close()
|
||||
|
||||
_, _, err = awaitFirecracker(context.Background(), ack, 1, time.Second)
|
||||
require.ErrorIs(t, err, ErrNotArmed)
|
||||
assert.Contains(t, err.Error(), "not the supervisor's child")
|
||||
}
|
||||
|
||||
// The archive the daemon writes is read by the guest's own identity reader
|
||||
// once extracted, as the runtime image's init extracts it.
|
||||
func TestConfigArchiveIsReadByTheGuestReaders(t *testing.T) {
|
||||
gen, err := guestconn.NewGeneration("ap-southeast-2/000000000001/fn/env/1", time.Hour)
|
||||
require.NoError(t, err)
|
||||
entries := map[string][]byte{fwCfgNetcfg: []byte("NIC0_MAC=02:00:00:00:00:01\n")}
|
||||
for name, b := range gen.GuestEntries("192.0.2.1:4000") {
|
||||
entries[lambdav1.FwCfgPrefix+name] = b
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), configArchive)
|
||||
require.NoError(t, writeConfigArchive(path, entries))
|
||||
|
||||
root := filepath.Join(t.TempDir(), "cfg")
|
||||
extract(t, path, root)
|
||||
_, err = hostlink.LoadIdentity(filepath.Join(root, "by_name", lambdav1.FwCfgPrefix))
|
||||
require.NoError(t, err)
|
||||
netcfg, err := os.ReadFile(filepath.Join(root, "by_name", fwCfgNetcfg, "raw"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entries[fwCfgNetcfg], netcfg)
|
||||
}
|
||||
|
||||
func extract(t *testing.T, archive, root string) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(archive)
|
||||
require.NoError(t, err)
|
||||
tr := tar.NewReader(bytes.NewReader(b))
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.False(t, strings.Contains(h.Name, ".."), h.Name)
|
||||
p := filepath.Join(root, h.Name)
|
||||
if h.Typeflag == tar.TypeDir {
|
||||
require.NoError(t, os.MkdirAll(p, 0o700))
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(tr)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(p, data, 0o600))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
)
|
||||
|
||||
// Readiness admits a function the test link can run, and only while d serves
|
||||
// the pinned image on the cluster's only node, so no gateway or daemon is
|
||||
// handed an Active function it cannot run.
|
||||
func (d *Daemon) Readiness(imagePin string, nodes int) function.Readiness {
|
||||
return func(_ context.Context, rec function.Record) error {
|
||||
if rec.EnvCipher != "" {
|
||||
return notReady("The function cannot run here: test-link Lambda does not support environment variables.")
|
||||
}
|
||||
if !validHandler(rec.Handler) {
|
||||
return notReady("The function cannot run here: test-link Lambda runs only a handler of 1 to 128 characters with no whitespace.")
|
||||
}
|
||||
if rec.Code.Size <= 0 || rec.Code.Size > MaxCodeBytes {
|
||||
return notReady("The function cannot run here: its code is larger than test-link Lambda accepts.")
|
||||
}
|
||||
if nodes != 1 {
|
||||
return notReady("The function cannot run here: test-link Lambda activates only on a single-node cluster.")
|
||||
}
|
||||
if !d.Serving() || d.Image == nil || d.Image.Digest != imagePin {
|
||||
return notReady(function.ReasonCreating)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// notReady is an unmet activation condition, worded as the StateReason.
|
||||
type notReady string
|
||||
|
||||
func (n notReady) Error() string { return string(n) }
|
||||
@@ -0,0 +1,173 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
//test:in-package — reuses the package fixtures writeImage, testImageDigest and controlName
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
readyAccount = "000000000001"
|
||||
readyRegion = "ap-southeast-2"
|
||||
)
|
||||
|
||||
// storedFunction creates a function through the real owner and records its
|
||||
// code as stored, leaving it Pending the way CreateFunction does.
|
||||
func storedFunction(t *testing.T, svc *function.MetaService, name string) {
|
||||
t.Helper()
|
||||
storedFunctionWith(t, svc, name, "", 9)
|
||||
}
|
||||
|
||||
func storedFunctionWith(t *testing.T, svc *function.MetaService, name, envCipher string, size int64) {
|
||||
t.Helper()
|
||||
storedFunctionHandler(t, svc, name, "bootstrap", envCipher, size)
|
||||
}
|
||||
|
||||
func storedFunctionHandler(t *testing.T, svc *function.MetaService, name, handler, envCipher string, size int64) {
|
||||
t.Helper()
|
||||
created, err := svc.Create(t.Context(), &function.CreateRequest{Region: readyRegion, Record: function.Record{
|
||||
FunctionID: "id-" + name, FunctionName: name, Role: "arn:aws:iam::000000000001:role/r",
|
||||
Runtime: "provided.al2023", Handler: handler, Timeout: 3, MemorySize: 128, EnvCipher: envCipher,
|
||||
Code: function.Code{RevisionID: "rev-" + name},
|
||||
}}, readyAccount)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.CodeStored(t.Context(), &function.CodeStoredRequest{Region: readyRegion, FunctionName: name,
|
||||
Revision: created.Revision, SHA256: testImageDigest, Size: size}, readyAccount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func functionState(t *testing.T, svc *function.MetaService, name string) function.Record {
|
||||
t.Helper()
|
||||
got, err := svc.Get(t.Context(), &function.GetRequest{Region: readyRegion, FunctionName: name}, readyAccount)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.Found)
|
||||
return got.Record
|
||||
}
|
||||
|
||||
func readyOwner(t *testing.T) *function.MetaService {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
return function.NewMetaService(testutil.NewJetStream(t, nc))
|
||||
}
|
||||
|
||||
func TestReadiness_ActivatesOnlyOnceTheDaemonServesThePinnedImage(t *testing.T) {
|
||||
svc := readyOwner(t)
|
||||
im, err := PrepareImage(writeImage(t), testImageDigest)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(im.Close)
|
||||
d := &Daemon{Node: "node1", Image: im}
|
||||
svc.SetReadiness(d.Readiness(testImageDigest, 1))
|
||||
storedFunction(t, svc, "hello")
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
waiting := functionState(t, svc, "hello")
|
||||
assert.Equal(t, function.StatePending, waiting.State, "a prepared image that is not served activates nothing")
|
||||
assert.Equal(t, function.ReasonCreating, waiting.StateReason)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
require.NoError(t, d.ServeControl(ctx, controlName(t), os.Getuid()))
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
active := functionState(t, svc, "hello")
|
||||
assert.Equal(t, function.StateActive, active.State)
|
||||
assert.Empty(t, active.StateReason)
|
||||
|
||||
cancel()
|
||||
require.Eventually(t, func() bool { return !d.Serving() }, 5*time.Second, 10*time.Millisecond,
|
||||
"a stopped control socket is reported as not serving")
|
||||
storedFunction(t, svc, "later")
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, "later").State)
|
||||
}
|
||||
|
||||
func TestReadiness_RefusesAnotherPinOrAnotherNode(t *testing.T) {
|
||||
im, err := PrepareImage(writeImage(t), testImageDigest)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(im.Close)
|
||||
d := &Daemon{Node: "node1", Image: im}
|
||||
require.NoError(t, d.ServeControl(t.Context(), controlName(t), os.Getuid()))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pin string
|
||||
nodes int
|
||||
reason string
|
||||
}{
|
||||
{"other pin", "0000000000000000000000000000000000000000000000000000000000000000", 1, function.ReasonCreating},
|
||||
{"two nodes", testImageDigest, 2, "The function cannot run here: test-link Lambda activates only on a single-node cluster."},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := readyOwner(t)
|
||||
svc.SetReadiness(d.Readiness(tc.pin, tc.nodes))
|
||||
storedFunction(t, svc, "hello")
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
got := functionState(t, svc, "hello")
|
||||
assert.Equal(t, function.StatePending, got.State)
|
||||
assert.Equal(t, tc.reason, got.StateReason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadiness_RefusesFunctionsTheTestLinkCannotRun(t *testing.T) {
|
||||
im, err := PrepareImage(writeImage(t), testImageDigest)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(im.Close)
|
||||
d := &Daemon{Node: "node1", Image: im}
|
||||
require.NoError(t, d.ServeControl(t.Context(), controlName(t), os.Getuid()))
|
||||
svc := readyOwner(t)
|
||||
svc.SetReadiness(d.Readiness(testImageDigest, 1))
|
||||
storedFunctionWith(t, svc, "env", "sealed", 9)
|
||||
storedFunctionWith(t, svc, "huge", "", MaxCodeBytes+1)
|
||||
storedFunctionWith(t, svc, "edge", "", MaxCodeBytes)
|
||||
storedFunctionHandler(t, svc, "spaced", "boot\u00a0strap", "", 9)
|
||||
storedFunctionHandler(t, svc, "longhandler", strings.Repeat("h", 129), "", 9)
|
||||
storedFunctionHandler(t, svc, "edgehandler", strings.Repeat("h", 128), "", 9)
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
assert.Equal(t, "The function cannot run here: test-link Lambda does not support environment variables.", functionState(t, svc, "env").StateReason)
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, "env").State)
|
||||
assert.Equal(t, "The function cannot run here: its code is larger than test-link Lambda accepts.", functionState(t, svc, "huge").StateReason)
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, "huge").State)
|
||||
assert.Equal(t, function.StateActive, functionState(t, svc, "edge").State, "the largest accepted code copy activates")
|
||||
for _, name := range []string{"spaced", "longhandler"} {
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, name).State, name)
|
||||
assert.Equal(t, "The function cannot run here: test-link Lambda runs only a handler of 1 to 128 characters with no whitespace.", functionState(t, svc, name).StateReason, name)
|
||||
}
|
||||
assert.Equal(t, function.StateActive, functionState(t, svc, "edgehandler").State, "the longest accepted handler activates")
|
||||
}
|
||||
|
||||
func TestReadiness_ServingEndsWhenAcceptingStops(t *testing.T) {
|
||||
d := &Daemon{Node: "node1"}
|
||||
name := controlName(t)
|
||||
ln, err := ListenControl(name)
|
||||
require.NoError(t, err)
|
||||
inHandle, release := make(chan struct{}), make(chan struct{})
|
||||
srv := d.controlServer(os.Getuid())
|
||||
srv.Handle = func(context.Context, []byte, Credential) Reply {
|
||||
close(inHandle)
|
||||
<-release
|
||||
return Reply{Outcome: OutcomeDone}
|
||||
}
|
||||
d.serve(t.Context(), ln, srv)
|
||||
require.True(t, d.Serving())
|
||||
go func() { _, _ = (&ControlClient{Name: name, DaemonUID: os.Getuid(), RequestLimit: MaxFrameBytes, ReplyLimit: MaxFrameBytes, IOTimeout: 5 * time.Second}).Exchange(context.Background(), []byte("{}"), testCred, time.Now().Add(5*time.Second)) }()
|
||||
select {
|
||||
case <-inHandle:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the request never reached Handle")
|
||||
}
|
||||
defer close(release)
|
||||
|
||||
require.NoError(t, ln.Close(), "the listener fails under an outstanding invocation")
|
||||
assert.Eventually(t, func() bool { return !d.Serving() }, 2*time.Second, 10*time.Millisecond,
|
||||
"serving ends while the invocation is still held, not after the drain")
|
||||
}
|
||||
@@ -3,14 +3,18 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/execution"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/host"
|
||||
@@ -27,6 +31,28 @@ var lambdaHardKillSHA256 string
|
||||
// image bytes and does not authenticate where they came from.
|
||||
var lambdaImageDigest string
|
||||
|
||||
// lambdaFirecrackerSHA256 pins the Firecracker v1.17.0 x86_64 release binary.
|
||||
// A node whose test-link image directory holds a firecracker binary boots
|
||||
// every attempt under it, and does not serve unless the binary matches.
|
||||
const lambdaFirecrackerSHA256 = "99ad0f5cd0514a88aad0e9ae8cfdb3cc3b4ab9d190e1194602406c786b5de7a5"
|
||||
|
||||
// checkFirecracker refuses a Firecracker binary other than the pinned release.
|
||||
func checkFirecracker(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("firecracker binary: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("firecracker binary: %w", err)
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != lambdaFirecrackerSHA256 {
|
||||
return errors.New("firecracker binary does not match the pinned release")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startLambdaTestLink serves controlled-test Lambda invocations for this node.
|
||||
// Recovery needs only the owner store, taps and addresses, so it starts even
|
||||
// when the bridge address, initializer, pin or download trust would not serve.
|
||||
@@ -70,9 +96,20 @@ func (d *Daemon) startLambdaTestLink() {
|
||||
InitializerSHA256: lambdaHardKillSHA256,
|
||||
HTTP: httpClient,
|
||||
}
|
||||
fc := filepath.Join(imageDir, "firecracker")
|
||||
if _, err := os.Lstat(fc); err == nil {
|
||||
tl.Firecracker = fc
|
||||
}
|
||||
err = runLambdaTestLink(
|
||||
func() { go tl.RecoverLoop(d.ctx, testlink.RecoverFirst, testlink.RecoverMax) },
|
||||
func() error { return lambdaTestLinkReady(tl, imageDir, lambdaImageDigest, trustErr) },
|
||||
func() error {
|
||||
if tl.Firecracker != "" {
|
||||
if err := checkFirecracker(tl.Firecracker); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return lambdaTestLinkReady(tl, imageDir, lambdaImageDigest, trustErr)
|
||||
},
|
||||
func() error {
|
||||
gatewayUID, err := testlink.LookupUID(testlink.GatewayUser)
|
||||
if err != nil {
|
||||
@@ -88,8 +125,17 @@ func (d *Daemon) startLambdaTestLink() {
|
||||
}
|
||||
slog.Warn("Lambda test link serving: test-only, not for production; the guest does not verify owner grant authority",
|
||||
"node", d.node, "image_digest", tl.Image.Digest)
|
||||
if d.lambdaMetaService != nil && d.clusterConfig != nil {
|
||||
d.lambdaMetaService.SetReadiness(tl.Readiness(lambdaImageDigest, len(d.clusterConfig.Nodes)))
|
||||
go d.lambdaMetaService.RunActivation(d.ctx, lambdaActivationInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// lambdaActivationInterval bounds how long a function whose activation was
|
||||
// missed, by a crash or an earlier refusal, waits for the next pass.
|
||||
const lambdaActivationInterval = 30 * time.Second
|
||||
|
||||
|
||||
// lambdaTestLinkReady runs the serving checks and, only once they pass,
|
||||
// prepares the pinned image into tl; a refusal leaves tl.Image nil.
|
||||
func lambdaTestLinkReady(tl *testlink.Daemon, imageDir, imagePin string, trustErr error) error {
|
||||
|
||||
Reference in New Issue
Block a user