Compare commits

...

6 Commits

Author SHA1 Message Date
joshsiv-mulga cb54792cc5 test: send the prepared digest so owner-refusal cases reach the owner 2026-11-05 16:48:41 +10:00
joshsiv-mulga c2cfa1053c test: assert the at-limit request is exactly the probed size 2026-11-05 16:31:46 +10:00
joshsiv-mulga b58c2f002b test: cover withdraw faults, image replacement and the digest's share of the request bound 2026-11-05 16:29:19 +10:00
joshsiv-mulga fea0058981 test: tie the mixed-version control test to the production socket name
The gateway logs a missing or malformed image pin once at startup.
2026-11-05 16:07:23 +10:00
joshsiv-mulga d3a4554455 feat: bind the pinned runtime image digest to each test-link Invoke
The gateway carries the daemon's image digest pin and sends it in every
request; the daemon refuses before Start when it is missing or differs. The
control socket moves to a versioned name so mixed gateway and daemon builds
never connect.
2026-11-05 16:04:45 +10:00
joshsiv-mulga 04ce8e68d2 fix: hold the test-link slot when the grant start outcome is unknown 2026-11-05 15:54:01 +10:00
12 changed files with 263 additions and 14 deletions
+3 -2
View File
@@ -90,6 +90,7 @@ go_build:
LAMBDA_TESTLINK_DIR := ./bin/lambda-testlink
LAMBDA_TESTLINK_PKG := github.com/mulgadc/spinifex/spinifex/runtime/daemon
LAMBDA_TESTLINK_PIN := $(LAMBDA_TESTLINK_PKG).lambdaHardKillSHA256
LAMBDA_TESTLINK_GW := github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw
LAMBDA_DIGEST_RE := ^[0-9a-f]{64}$$
build-lambda-testlink: override export LAMBDA_IMAGE_DIGEST := $(value LAMBDA_IMAGE_DIGEST)
build-lambda-testlink:
@@ -104,8 +105,8 @@ build-lambda-testlink:
@digest=$$(sha256sum $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so | cut -d' ' -f1); \
test $${#digest} -eq 64 || { echo "initializer digest missing"; exit 1; }; \
echo "initializer sha256 $$digest"; \
pin="-X $(LAMBDA_TESTLINK_PIN)=$$digest -X $(LAMBDA_TESTLINK_PKG).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST"; \
LAMBDA_TESTLINK_INITIALIZER=$(abspath $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so) go test -count=1 -tags lambdatestlink -ldflags "$$pin" -run '^TestLambda(HardKillPin|ImageDigestPin)$$' ./spinifex/runtime/daemon/ && \
pin="-X $(LAMBDA_TESTLINK_PIN)=$$digest -X $(LAMBDA_TESTLINK_PKG).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST -X $(LAMBDA_TESTLINK_GW).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST"; \
LAMBDA_TESTLINK_INITIALIZER=$(abspath $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so) go test -count=1 -tags lambdatestlink -ldflags "$$pin" -run '^TestLambda(HardKillPin|ImageDigestPin)$$' ./spinifex/runtime/daemon/ ./spinifex/runtime/roles/awsgw/ && \
go build $(GO_BUILD_MOD) -tags lambdatestlink -ldflags "$(subst -s -w ,,$(LDFLAGS)) $$pin" -o $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) cmd/spinifex/main.go
@mkdir -p $(LAMBDA_TESTLINK_DIR)
mv $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) $(LAMBDA_TESTLINK_DIR)/
+1
View File
@@ -22,6 +22,7 @@ Tenant code may be able to reach the listener, but it cannot open the agent's co
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/2` on both host and guest, whose first frame carries the execution-role credential, so a test-link end and a release end fail the handshake; release builds are unchanged.
In the same builds, the gateway reaches the host daemon on the local abstract socket `spinifex-lambda-testlink-control/2`, and each request carries the runtime-image digest the gateway was built with; the daemon refuses a missing or different digest before starting the attempt, and a gateway and daemon on different socket names never connect.
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
@@ -49,6 +49,8 @@ type Request struct {
CodeURL string `json:"code_url"`
CodeSize int64 `json:"code_size"`
CodeSHA256 string `json:"code_sha256"`
// ImageDigest is the runtime image the gateway expects to run the code.
ImageDigest string `json:"image_digest"`
Payload []byte `json:"payload"`
// cred arrives beside the request on the control socket, never inside it.
@@ -23,7 +23,7 @@ import (
// ControlName is the abstract Unix socket name the node's daemon serves. An
// abstract name has no directory entry; the kernel frees it when the last
// reference to the listening socket closes.
const ControlName = "spinifex-lambda-testlink-control"
const ControlName = "spinifex-lambda-testlink-control/2"
// Service accounts at each end of the control socket.
const (
@@ -435,3 +435,25 @@ func TestControl_WriteErrorAfterDeliveryIsUnknown(t *testing.T) {
require.Equal(t, []byte(`{"attempt_id":"a"}`), s.req.Load())
require.Equal(t, testCred, s.cred.Load())
}
// legacyControlName is the protocol name before the request carried the image
// digest; an old daemon still serves it and ignores the field.
const legacyControlName = "spinifex-lambda-testlink-control"
// A gateway and daemon on different control protocol names never connect, in
// either direction. The scratch prefix isolates the binds; the names after it
// are the production constant and its predecessor.
func TestControl_MixedProtocolNamesNeverConnect(t *testing.T) {
prefix := controlName(t) + "/"
for name, tc := range map[string]struct{ daemon, gateway string }{
"old daemon, new gateway": {prefix + legacyControlName, prefix + ControlName},
"new daemon, old gateway": {prefix + ControlName, prefix + legacyControlName},
} {
t.Run(name, func(t *testing.T) {
s, _ := serveControl(t, tc.daemon, os.Getuid(), nil)
_, err := client(tc.gateway, os.Getuid()).Exchange(t.Context(), []byte(`{"attempt_id":"a"}`), testCred, soon())
require.ErrorIs(t, err, ErrNotSent)
assert.Equal(t, int32(0), s.calls.Load())
})
}
}
@@ -149,6 +149,9 @@ func (d *Daemon) Handle(ctx context.Context, data []byte, cred Credential) Reply
if d.Image == nil {
return refused("runtime image not prepared")
}
if req.ImageDigest != d.Image.Digest {
return refused("runtime image digest differs from the prepared image")
}
if !d.busy.CompareAndSwap(false, true) {
return refused("another test-link attempt holds this node's slot")
}
@@ -193,7 +196,7 @@ func (d *Daemon) attempt(ctx context.Context, req Request, launch func(code []by
if err := d.Grants.Start(ctx, req.Grant, req.Identity()); err != nil {
if errors.Is(err, execution.ErrOutcomeUnknown) {
slog.Error("Lambda test link: grant start outcome unknown; node held", "attempt", req.AttemptID, "err", err)
return Reply{Outcome: OutcomeUnknown, Error: "grant start outcome unknown"}, true
return Reply{Outcome: OutcomeUnknown, Error: "grant start outcome unknown"}, false
}
return refused(err.Error()), true
}
@@ -121,13 +121,18 @@ func baseRequest(url string, code []byte) Request {
return Request{
AttemptID: "0123456789ab", RequestID: "r", Region: "ap-southeast-2", AccountID: "123456789012",
FunctionID: "fn1", RevisionID: "rev1", Handler: "bootstrap.handler", TimeoutMs: 3000, CodeURL: url, CodeSize: int64(len(code)),
CodeSHA256: hex.EncodeToString(sum[:]),
CodeSHA256: hex.EncodeToString(sum[:]), ImageDigest: testImageDigest,
}
}
func (g *granted) request(url string, code []byte) []byte {
return g.requestFor(url, code, g.image.Digest)
}
func (g *granted) requestFor(url string, code []byte, imageDigest string) []byte {
req := baseRequest(url, code)
req.Grant = g.att.Grant
req.ImageDigest = imageDigest
b, _ := json.Marshal(req)
return b
}
@@ -148,6 +153,10 @@ func TestDaemon_RefusesBeforeAllocating(t *testing.T) {
b, _ := json.Marshal(baseRequest(good.URL, code))
return b
}, execution.StateGranted},
// A gateway built for another runtime image, or an old one that sends
// no digest, is refused before Start.
"image digest differs": {func(g *granted) []byte { return g.requestFor(good.URL, code, strings.Repeat("0", 64)) }, execution.StateGranted},
"image digest missing": {func(g *granted) []byte { return g.requestFor(good.URL, code, "") }, execution.StateGranted},
// Refused after Start with nothing launched: the daemon records the end.
"digest mismatch": {func(g *granted) []byte { return g.request(wrong.URL, code) }, execution.StateEnded},
"size mismatch": {func(g *granted) []byte { return g.request(short.URL, code) }, execution.StateEnded},
@@ -164,6 +173,7 @@ func TestDaemon_RefusesBeforeAllocating(t *testing.T) {
rep := g.daemon(&Daemon{IPs: ips, Taps: taps, HTTP: client, WorkDir: t.TempDir()}).Handle(context.Background(), tc.data(g), testCred)
require.Equal(t, OutcomeRefused, rep.Outcome, rep.Error)
require.Empty(t, ips.allocated)
require.Empty(t, taps.created)
require.NotContains(t, rep.Error, good.URL)
require.Equal(t, tc.state, g.state(t))
})
@@ -198,6 +208,7 @@ func TestDaemon_OwnerRefusalComesBeforeAnyAllocation(t *testing.T) {
ips := &fakeIPs{}
rep := g.daemon(&Daemon{IPs: ips, Taps: &fakeTaps{}, HTTP: srv.Client(), WorkDir: t.TempDir()}).Handle(context.Background(), data, testCred)
require.Equal(t, OutcomeRefused, rep.Outcome)
require.NotContains(t, rep.Error, "image digest", "the owner, not the digest check, refuses")
require.False(t, fetched)
require.Empty(t, ips.allocated)
require.Equal(t, execution.StateGranted, g.state(t))
@@ -447,3 +458,65 @@ func TestCheckRequest_RefusesUnusableHandler(t *testing.T) {
})
}
}
// unconfirmedStart lets the owner write the start, then reports the outcome
// as unknown, as a lost acknowledgement would.
type unconfirmedStart struct{ *execution.Store }
func (u unconfirmedStart) Start(ctx context.Context, g execution.Grant, want execution.Identity) error {
if err := u.Store.Start(ctx, g, want); err != nil {
return err
}
return execution.ErrOutcomeUnknown
}
func TestDaemon_UnknownStartOutcomeHoldsTheSlot(t *testing.T) {
code := []byte("x")
g := grant(t, code)
ips, taps := &fakeIPs{}, &fakeTaps{}
d := g.daemon(&Daemon{IPs: ips, Taps: taps, WorkDir: t.TempDir()})
d.Grants = unconfirmedStart{g.store}
rep := d.Handle(context.Background(), g.request("https://h/x", code), testCred)
require.Equal(t, OutcomeUnknown, rep.Outcome)
require.True(t, d.busy.Load(), "an unconfirmed start may have taken the node")
require.Empty(t, ips.allocated)
require.Equal(t, execution.StateRunning, g.state(t))
rep = d.Handle(context.Background(), g.request("https://h/x", code), testCred)
require.Equal(t, OutcomeRefused, rep.Outcome)
require.Contains(t, rep.Error, "holds this node's slot")
}
// A daemon whose image is replaced between two Invokes refuses the second
// before Start, though the first passed the digest check.
func TestDaemon_ImageReplacedBetweenInvokesRefusesBeforeStart(t *testing.T) {
code := []byte("zip bytes")
wrong := codeServer(t, []byte("other one"))
g := grant(t, code)
ips, taps := &fakeIPs{}, &fakeTaps{}
d := g.daemon(&Daemon{IPs: ips, Taps: taps, HTTP: wrong.Client(), WorkDir: t.TempDir()})
rep := d.Handle(context.Background(), g.request(wrong.URL, code), testCred)
require.Equal(t, OutcomeRefused, rep.Outcome)
require.Equal(t, execution.StateEnded, g.state(t), "the first request passed the digest check and started")
second, err := g.store.Issue(context.Background(), execution.Attempt{
Grant: execution.Grant{AttemptID: "0123456789ac", EnvironmentID: "env-0123456789ac", Generation: 1, Node: testNode},
Identity: baseRequest("", code).Identity(),
LaunchBy: time.Now().Add(time.Minute),
})
require.NoError(t, err)
d.Image = &Image{Digest: strings.Repeat("1", 64)}
req := baseRequest(wrong.URL, code)
req.AttemptID, req.Grant, req.ImageDigest = second.Grant.AttemptID, second.Grant, g.image.Digest
b, err := json.Marshal(req)
require.NoError(t, err)
rep = d.Handle(context.Background(), b, testCred)
require.Equal(t, OutcomeRefused, rep.Outcome)
require.Contains(t, rep.Error, "image digest differs")
require.Equal(t, execution.StateGranted, g.state(t), "the second grant was never started")
require.Empty(t, ips.allocated)
require.Empty(t, taps.created)
}
@@ -24,17 +24,15 @@ func lambdaTestExecutor(nc *nats.Conn, node string, codes *codestore.Manager, se
slog.Error("Lambda test link: control peer account unavailable; Invoke is not served", "err", err)
return nil
}
if !testlink.ValidDigest(lambdaImageDigest) {
slog.Error("Lambda test link: runtime image pin missing or malformed; every Invoke is refused")
}
return &testLinkExecutor{
Control: &testlink.ControlClient{
Name: testlink.ControlName,
DaemonUID: daemonUID,
RequestLimit: nc.MaxPayload(),
ReplyLimit: nc.MaxPayload(),
IOTimeout: controlWriteTimeout,
},
Control: testLinkControl(daemonUID, nc.MaxPayload()),
RequestLimit: nc.MaxPayload(),
Sessions: sessions,
Node: node,
ImageDigest: lambdaImageDigest,
Codes: func(ctx context.Context) (codePresigner, error) {
store, err := codes.Store(ctx)
if err != nil {
@@ -52,6 +50,21 @@ func lambdaTestExecutor(nc *nats.Conn, node string, codes *codestore.Manager, se
}
}
// testLinkControl is the gateway's client for this node's daemon socket.
func testLinkControl(daemonUID int, limit int64) *testlink.ControlClient {
return &testlink.ControlClient{
Name: testlink.ControlName,
DaemonUID: daemonUID,
RequestLimit: limit,
ReplyLimit: limit,
IOTimeout: controlWriteTimeout,
}
}
// lambdaImageDigest pins the runtime image this gateway expects the daemon
// to run; make build-lambda-testlink sets it to the daemon's pin.
var lambdaImageDigest string
// controlWriteTimeout bounds writing one request to the local daemon.
const controlWriteTimeout = 30 * time.Second
@@ -60,6 +60,8 @@ type testLinkExecutor struct {
RequestLimit int64
Sessions lambdaEnvironmentRoleIssuer
Node string
// ImageDigest is the runtime image every request requires of the daemon.
ImageDigest string
Codes func(context.Context) (codePresigner, error)
Grants func(context.Context) (grantIssuer, error)
now func() time.Time
@@ -73,6 +75,10 @@ var _ awsapi.Executor = (*testLinkExecutor)(nil)
// session is revoked on every path after it is issued.
func (e *testLinkExecutor) InvokeSync(ctx context.Context, in awsapi.SyncInvocation) (awsapi.SyncResult, error) {
rec := in.Function
if !testlink.ValidDigest(e.ImageDigest) {
return awsapi.SyncResult{}, &awsapi.RefusalError{Err: awserrors.Errorf(awserrors.ErrorServiceException,
"the test execution path has no valid runtime image pin")}
}
// The test link cannot deliver environment variables, so it refuses a
// function that has any before presigning, granting or sending.
if rec.EnvCipher != "" {
@@ -174,6 +180,7 @@ func (e *testLinkExecutor) InvokeSync(ctx context.Context, in awsapi.SyncInvocat
CodeURL: url,
CodeSize: rec.Code.Size,
CodeSHA256: rec.Code.SHA256,
ImageDigest: e.ImageDigest,
Payload: in.Payload,
})
if err != nil {
@@ -114,8 +114,11 @@ func executor(n *fakeControl, presignErr error, store *execution.Store) *testLin
return executorWith(n, &fakeSessions{}, presignErr, store)
}
// testImageDigest stands in for the build's pinned runtime image digest.
const testImageDigest = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12"
func executorWith(n *fakeControl, sessions *fakeSessions, presignErr error, store *execution.Store) *testLinkExecutor {
return &testLinkExecutor{Control: n, RequestLimit: n.max, Sessions: sessions, Node: "node1",
return &testLinkExecutor{Control: n, RequestLimit: n.max, Sessions: sessions, Node: "node1", ImageDigest: testImageDigest,
Codes: func(context.Context) (codePresigner, error) { return fakePresigner{err: presignErr}, nil },
Grants: func(context.Context) (grantIssuer, error) { return store, nil },
}
@@ -161,6 +164,25 @@ func TestLambdaTestLinkExecutor_RefusesWithoutSending(t *testing.T) {
}
}
// A gateway built without a valid image pin refuses before presigning,
// granting, issuing or connecting.
func TestLambdaTestLinkExecutor_InvalidImagePinRefusesBeforeConnecting(t *testing.T) {
for name, pin := range map[string]string{"missing": "", "malformed": "not-a-digest"} {
t.Run(name, func(t *testing.T) {
n, store := &fakeControl{max: 1 << 20, reply: &testlink.Reply{Outcome: testlink.OutcomeDone}}, grantStore(t)
sessions := &fakeSessions{}
e := executorWith(n, sessions, nil, store)
e.ImageDigest = pin
_, err := e.InvokeSync(context.Background(), invocation(nil))
var refusal *awsapi.RefusalError
require.ErrorAs(t, err, &refusal)
require.Empty(t, n.sent)
require.Empty(t, sessions.issued)
require.Empty(t, grantState(t, store), "no grant taken")
})
}
}
func TestLambdaTestLinkExecutor_BusyNodeSendsNothing(t *testing.T) {
store := grantStore(t)
held := &fakeControl{max: 1 << 20, err: errors.New("no reply")}
@@ -277,6 +299,7 @@ func TestLambdaTestLinkExecutor_IssuesForTheAttemptAndSendsOnTheSocket(t *testin
var sent testlink.Request
require.NoError(t, json.Unmarshal(n.sent[0], &sent))
require.Equal(t, testScope.FunctionARN+":$LATEST", sent.FunctionARN, "the runtime still sees the invoked ARN")
require.Equal(t, testImageDigest, sent.ImageDigest, "the daemon checks the gateway's pinned image")
require.NotContains(t, string(n.sent[0]), testSecret, "the credential travels beside the request, not in it")
require.Equal(t, testSecret, n.creds[0].SecretAccessKey)
@@ -399,3 +422,75 @@ func TestLambdaTestLinkExecutor_SilentDaemonIsUnknownAndRevoked(t *testing.T) {
require.Less(t, time.Since(start), 10*time.Second)
require.Len(t, sessions.revoked, 1)
}
// withdrawFault fails Withdraw before or after the owner writes it.
type withdrawFault struct {
*execution.Store
afterWrite bool
}
func (w withdrawFault) Withdraw(ctx context.Context, g execution.Grant) error {
if !w.afterWrite {
return errors.New("owner unavailable")
}
if err := w.Store.Withdraw(ctx, g); err != nil {
return err
}
return execution.ErrOutcomeUnknown
}
// A daemon refusal with a failed Withdraw leaves the grant startable: a
// replayed Start succeeds. A lost Withdraw acknowledgement leaves it fenced.
func TestLambdaTestLinkExecutor_RefusalWithdrawFaults(t *testing.T) {
for name, tc := range map[string]struct {
afterWrite bool
state string
replay bool
}{
"fails before write": {state: execution.StateGranted, replay: true},
"ack lost": {afterWrite: true, state: execution.StateWithdrawn},
} {
t.Run(name, func(t *testing.T) {
store := grantStore(t)
n := &fakeControl{max: 1 << 20, reply: &testlink.Reply{Outcome: testlink.OutcomeRefused, Error: "image differs"}}
e := executor(n, nil, store)
e.Grants = func(context.Context) (grantIssuer, error) { return withdrawFault{store, tc.afterWrite}, nil }
_, err := e.InvokeSync(context.Background(), invocation(nil))
require.Error(t, err)
require.Len(t, n.sent, 1)
require.Equal(t, tc.state, grantState(t, store))
var sent testlink.Request
require.NoError(t, json.Unmarshal(n.sent[0], &sent))
err = store.Start(context.Background(), sent.Grant, sent.Identity())
if tc.replay {
require.NoError(t, err, "an unwithdrawn grant can still start")
} else {
require.Error(t, err, "a withdrawn grant cannot start")
}
})
}
}
// The digest field counts toward the request bound: the exact encoded size
// is sent and one byte less is refused unsent.
func TestLambdaTestLinkExecutor_RequestBoundIncludesImageDigest(t *testing.T) {
probe := &fakeControl{max: 1 << 20, reply: &testlink.Reply{Outcome: testlink.OutcomeDone}}
_, err := executor(probe, nil, grantStore(t)).InvokeSync(context.Background(), invocation(make([]byte, 1000)))
require.NoError(t, err)
require.Len(t, probe.sent, 1)
require.Contains(t, string(probe.sent[0]), `"image_digest":"`+testImageDigest+`"`)
size := int64(len(probe.sent[0]))
at := &fakeControl{max: size, reply: &testlink.Reply{Outcome: testlink.OutcomeDone}}
_, err = executor(at, nil, grantStore(t)).InvokeSync(context.Background(), invocation(make([]byte, 1000)))
require.NoError(t, err)
require.Len(t, at.sent, 1)
require.Equal(t, size, int64(len(at.sent[0])), "the generated IDs are fixed-length, so the at-limit request is the probed size")
under := &fakeControl{max: size - 1}
_, err = executor(under, nil, grantStore(t)).InvokeSync(context.Background(), invocation(make([]byte, 1000)))
var refusal *awsapi.RefusalError
require.ErrorAs(t, err, &refusal)
require.Empty(t, under.sent)
}
@@ -0,0 +1,32 @@
//go:build lambdatestlink
//test:in-package — reads the unexported ldflags image pin
package awsgw
import (
"os"
"testing"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/testlink"
)
// TestLambdaImageDigestPin runs from make build-lambda-testlink with the
// daemon's ldflags: the gateway carries the same image digest as the daemon.
func TestLambdaImageDigestPin(t *testing.T) {
want := os.Getenv("LAMBDA_IMAGE_DIGEST")
if want == "" {
t.Skip("run by make build-lambda-testlink with a pinned image digest")
}
if lambdaImageDigest != want || !testlink.ValidDigest(lambdaImageDigest) {
t.Fatalf("pinned image digest %q, want %q", lambdaImageDigest, want)
}
}
// The gateway dials the production control name, so a mixed-version daemon on
// the previous name is never reached.
func TestLambdaTestLinkControlUsesTheProductionName(t *testing.T) {
c := testLinkControl(4242, 1<<20)
if c.Name != testlink.ControlName || c.DaemonUID != 4242 {
t.Fatalf("control client %q uid %d, want %q uid 4242", c.Name, c.DaemonUID, testlink.ControlName)
}
}
@@ -212,7 +212,7 @@ func composedExecutor(t *testing.T, env *seamEnv, codeURL string, client *http.C
grants := &countedGrants{grantIssuer: env.grants}
presigns := &atomic.Int32{}
daemon := startSeamDaemon(t, &testlink.Daemon{Node: "node1", Grants: env.grants, HTTP: client}, guest)
exec := &testLinkExecutor{Control: daemon.client, RequestLimit: 1 << 20, Sessions: env.sts, Node: "node1",
exec := &testLinkExecutor{Control: daemon.client, RequestLimit: 1 << 20, Sessions: env.sts, Node: "node1", ImageDigest: testImageDigest,
Codes: func(context.Context) (codePresigner, error) { return urlPresigner{codeURL, presigns}, nil },
Grants: func(context.Context) (grantIssuer, error) { return grants, nil },
}