Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5830d6c
Wire authenticated guest handoff and guarded recovery admission
jiashuoz Oct 2, 2026
bbcb5cb
Fence delayed RPC delivery and reject ambiguous bootstrap preambles
jiashuoz Oct 2, 2026
3622c8a
Require exclusive state ownership for every microVM runner
jiashuoz Oct 2, 2026
bd8c7ab
Fence cold resume placement before launch and reconcile uncertain res…
jiashuoz Oct 2, 2026
e322fad
Preserve cold launch ownership across crashes and terminal races
jiashuoz Oct 2, 2026
c691214
Retain fresh and interrupted VM launches until process exit is proven
jiashuoz Oct 2, 2026
0924960
Use exact pending placement capacity in API explanations
jiashuoz Oct 2, 2026
b3f57b8
Require exact instance and original process lifetime before VM teardown
jiashuoz Oct 2, 2026
4e4edca
Require host authority support to negotiate guest reconnect
jiashuoz Oct 2, 2026
775c0ce
fix: revalidate VM lifetime before shutdown escalation
jiashuoz Oct 2, 2026
cccf99e
feat: compose durable standalone guest recovery
jiashuoz Oct 2, 2026
05f6790
fix(sessiond): launch the command supplied by microVM boot configuration
jiashuoz Oct 6, 2026
8189c10
test(controld): await asynchronous placement dispatch before asserting
jiashuoz Oct 6, 2026
91810d5
fix(driver): recognize original VM exit before reaping
jiashuoz Oct 7, 2026
267485e
fix(driver): require whole process exit before teardown
jiashuoz Oct 7, 2026
40952a2
fix(microvm): wait for remaining recovered VMM threads
jiashuoz Oct 7, 2026
df8ec14
fix(microvm): resolve recovered cold resume configuration
jiashuoz Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/rainier/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func eligibilityText(verdict, note string) string {
// yet" from "never".
func attachNote(s session) string {
switch s.State {
case "queued", "creating":
case "queued", "creating", "resuming":
return "waits for it to start"
case "suspended_warm", "suspended_cold":
return "resumes it first"
Expand All @@ -141,7 +141,7 @@ func stopNote(s session) string {
return "this build does not know this state; the server decides"
}
switch s.State {
case "queued", "creating":
case "queued", "creating", "resuming":
return "only a running session can be stopped"
case "suspended_warm", "suspended_cold":
return "already stopped"
Expand Down
8 changes: 4 additions & 4 deletions cmd/rainier/sessionstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const (
// exited would be the collapse this file exists to prevent.
func lifecycleOf(s session) string {
switch s.State {
case "queued", "creating":
case "queued", "creating", "resuming":
return lifecycleStarting
case "running":
// Regardless of child_exit_code. The sandbox is up.
Expand Down Expand Up @@ -188,7 +188,7 @@ const (
// Everything else is refused by the endpoint.
func canAttach(s session) string {
switch s.State {
case "running", "queued", "creating", "suspended_warm", "suspended_cold":
case "running", "queued", "creating", "resuming", "suspended_warm", "suspended_cold":
return eligibleYes
case "failed":
if s.Reachable {
Expand Down Expand Up @@ -216,7 +216,7 @@ func canStop(s session) string {
switch s.State {
case "running":
return eligibleYes
case "queued", "creating", "suspended_warm", "suspended_cold",
case "queued", "creating", "resuming", "suspended_warm", "suspended_cold",
"failed", "dead", "canceled", "destroyed":
return eligibleNo
default:
Expand All @@ -231,7 +231,7 @@ func canStop(s session) string {
// a live container and deleting it is the cleanup.
func canDelete(s session) string {
switch s.State {
case "running", "queued", "suspended_warm", "suspended_cold",
case "running", "queued", "resuming", "suspended_warm", "suspended_cold",
"failed", "dead", "canceled":
return eligibleYes
case "creating":
Expand Down
2 changes: 2 additions & 0 deletions cmd/rainier/sessionstate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestThreeDimensions(t *testing.T) {
"creating", session{State: "creating", Reachable: true},
lifecycleStarting, processNone, connectionAvailable,
},
{"resuming", session{State: "resuming", Reachable: true}, lifecycleStarting, processNone, connectionAvailable},
{
"running with a live child", session{State: "running", Reachable: true},
lifecycleRunning, processRunning, connectionAvailable,
Expand Down Expand Up @@ -159,6 +160,7 @@ func TestActionEligibilityFollowsRawStates(t *testing.T) {
// it with a conflict because a dispatch may be in flight.
"creating", session{State: "creating"}, eligibleYes, eligibleNo, eligibleNo,
},
{"resuming", session{State: "resuming"}, eligibleYes, eligibleNo, eligibleYes},
{"suspended warm", session{State: "suspended_warm"}, eligibleYes, eligibleNo, eligibleYes},
{"suspended cold", session{State: "suspended_cold"}, eligibleYes, eligibleNo, eligibleYes},
{
Expand Down
2 changes: 1 addition & 1 deletion cmd/rainier/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func initialAttachGuidance(ctx context.Context, cfg cli.Config, id string, since
} else {
row := resp.Session
switch row.State {
case "queued", "creating":
case "queued", "creating", "resuming":
explanation = "session is " + row.State + " and not ready to attach"
if row.QueueReason != "" {
// Queue text is server-owned prose. Redact known credentials before
Expand Down
19 changes: 11 additions & 8 deletions cmd/runnerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func main() {
"bearer token for the controld dial (required when --controld is set; or set RAINIER_RUNNER_TOKEN, which keeps it out of the process list)")
driverFlag := flag.String("driver", envDefault("RAINIER_RUNNER_DRIVER", "docker"),
"execution driver for session sandboxes: docker | microvm")
microvmGuestReconnect := flag.Bool("microvm-guest-reconnect", os.Getenv("RAINIER_MICROVM_GUEST_RECONNECT") == "1",
"opt in to authenticated recovery of surviving microVM guests after control connection or runner restart; requires a matching control plane and guest image")
kernelPath := flag.String("kernel", envDefault("RAINIER_KERNEL_PATH", ""),
"guest vmlinux kernel path for the microvm driver (required when --driver=microvm; the runner refuses to start without a readable one)")
rootfsPath := flag.String("rootfs", envDefault("RAINIER_ROOTFS_PATH", ""),
Expand Down Expand Up @@ -149,14 +151,15 @@ func main() {
log.Fatalf("--driver=microvm: %v", err)
}
mvm, err := driver.NewMicrovm(driver.MicrovmOpts{
Checkpoint: ckpt,
KernelPath: *kernelPath,
BaseRootfs: *rootfsPath,
StateDir: *microvmStateDir,
TotalSlots: *slots,
VCPU: *microvmVCPUs,
MemoryMiB: *microvmMemoryMiB,
ImageSource: imageSource,
GuestReconnect: *microvmGuestReconnect,
Checkpoint: ckpt,
KernelPath: *kernelPath,
BaseRootfs: *rootfsPath,
StateDir: *microvmStateDir,
TotalSlots: *slots,
VCPU: *microvmVCPUs,
MemoryMiB: *microvmMemoryMiB,
ImageSource: imageSource,

SlotGuestCIDR: *microvmGuestCIDR,
SlotUplinkCIDR: *microvmUplinkCIDR,
Expand Down
31 changes: 31 additions & 0 deletions cmd/sessiond/bootlaunch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"context"
"slices"

"github.com/tokencanopy/rainier/internal/relay"
"github.com/tokencanopy/rainier/protocol/runner"
)

// guestBoot is the launch state consumed by main before preparing the boot chain.
type guestBoot struct {
conn relay.Conn
config runner.BootConfig
argv []string
failure *bootFailure
}

func bootGuest(ctx context.Context, dial dialSession, boots *bootstrapper, imageCommand []string) (guestBoot, error) {
conn, cfg, failure, err := bootOverVsock(ctx, dial, boots)
if err != nil {
return guestBoot{}, err
}
// The image command only supplies a fallback. A microVM's per-session
// command arrives on the authenticated boot channel, not in PID 1's argv.
command := imageCommand
if len(cfg.Cmd) > 0 {
command = cfg.Cmd
}
return guestBoot{conn: conn, config: cfg, argv: slices.Clone(command), failure: failure}, nil
}
55 changes: 55 additions & 0 deletions cmd/sessiond/bootlaunch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"context"
"os/exec"
"testing"
"time"

"github.com/tokencanopy/rainier/protocol/runner"
)

func TestGuestBootLaunchesRequestedCommand(t *testing.T) {
for _, requested := range []bool{true, false} {
name := "image_fallback"
if requested {
name = "requested_command"
}
t.Run(name, func(t *testing.T) {
cleanEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dial, hosts := fakeTransport(t)
type result struct {
boot guestBoot
err error
}
done := make(chan result, 1)
go func() {
boot, err := bootGuest(ctx, dial, &bootstrapper{}, []string{"/bin/sh", "-c", "printf image_default"})
done <- result{boot, err}
}()
host := <-hosts
cfg := runner.BootConfig{Protocol: runner.SessionBootstrapProtocolVersion, SessionID: "session_command_test"}
want := "image_default"
if requested {
// Shell-looking text must remain one literal argument, not be re-parsed.
want = "requested ; $(false) ' quoted"
cfg.Cmd = []string{"/bin/sh", "-c", `printf '%s' "$1"`, "command-test", want}
}
host.sendBootConfig(cfg)
got := <-done
if got.err != nil || got.boot.failure != nil {
t.Fatalf("guest boot failed: %v %v", got.err, got.boot.failure)
}
defer got.boot.conn.Close()
output, err := exec.CommandContext(ctx, got.boot.argv[0], got.boot.argv[1:]...).Output()
if err != nil {
t.Fatal(err)
}
if string(output) != want {
t.Fatalf("launched output %q, want %q", output, want)
}
})
}
}
11 changes: 6 additions & 5 deletions cmd/sessiond/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,21 @@ func main() {
if *transport == transportVsock {
dialer = vsockTransport()
preamble = func(ctx context.Context, c relay.Conn) error { return reBootstrap(ctx, c, boots) }
conn, cfg, failure, err := bootOverVsock(context.Background(), dialer, boots)
boot, err := bootGuest(context.Background(), dialer, boots, argv)
if err != nil {
// Deliberately fatal, and the same judgement prepareBoot's own
// failure gets: a guest that could not read its configuration
// does not know what session it is, what to run, or where its
// egress goes. Dying is what makes the runner notice.
log.Fatalf("microvm boot: %v", err)
}
firstConn, overVsock = conn, true
firstConn, overVsock = boot.conn, true
argv = boot.argv
if *sessionID == "" {
*sessionID = cfg.SessionID
*sessionID = boot.config.SessionID
}
if failure != nil {
secretsFailure = failure.Error()
if boot.failure != nil {
secretsFailure = boot.failure.Error()
}
} else if *transport != transportWebSocket {
log.Fatalf("unknown --transport %q (valid: %s, %s)", *transport, transportWebSocket, transportVsock)
Expand Down
25 changes: 23 additions & 2 deletions cmd/sessiond/reconnect.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
Expand All @@ -29,6 +30,7 @@ type guestReconnectIdentity struct {
key ed25519.PrivateKey
enrolled bool
epoch uint64
launch [32]byte
}

func (b *bootstrapper) enrollGuest(ctx context.Context, c relay.Conn, cfg runner.BootConfig) (map[string]string, error) {
Expand All @@ -38,7 +40,7 @@ func (b *bootstrapper) enrollGuest(ctx context.Context, c relay.Conn, cfg runner
return nil, errGuestReconnect
}
// A failed or unsupported opt-in is sticky: later dials cannot downgrade.
g := &guestReconnectIdentity{session: cfg.SessionID}
g := &guestReconnectIdentity{session: cfg.SessionID, launch: guestLaunchIdentity(cfg)}
b.guest = g
b.mu.Unlock()
if cfg.GuestReconnect != runner.GuestReconnectProtocol || !guestID(cfg.SessionID) {
Expand Down Expand Up @@ -106,7 +108,7 @@ func (b *bootstrapper) reconnectGuest(ctx context.Context, c relay.Conn) error {
delivery, cancel := context.WithTimeout(ctx, time.Duration(accepted.ExpiresInSec)*time.Second)
defer cancel()
cfg, err := readBootConfig(delivery, c)
if err != nil || cfg.SessionID != session || cfg.GuestReconnect != 1 || cfg.BootstrapToken != accepted.Token {
if err != nil || cfg.SessionID != session || cfg.GuestReconnect != 1 || cfg.BootstrapToken != accepted.Token || guestLaunchIdentity(cfg) != g.launch {
return errGuestReconnect
}
b.mu.Lock()
Expand Down Expand Up @@ -335,3 +337,22 @@ func guestReady(ctx context.Context, c relay.Conn, epoch uint64) error {
}
return nil
}

// The live boot retains only a digest of fields whose consumers run once.
// Repositories, git attribution, setup, proxy policy and agent custody paths
// cannot be refreshed merely by changing sessiond's environment. A changed
// launch requires a new boot; ordinary environment values and secret names may
// still refresh. The digest never reaches the host or durable storage.
func guestLaunchIdentity(cfg runner.BootConfig) [32]byte {
cfg.BootstrapToken = ""
cfg.SecretNames = nil
launchEnv := map[string]string{}
for key, value := range cfg.Env {
if key == "RAINIER_AGENTS_B64" || strings.HasPrefix(value, "/rainier/agents/") {
launchEnv[key] = value
}
}
cfg.Env = launchEnv
body, _ := json.Marshal(cfg)
return sha256.Sum256(body)
}
40 changes: 40 additions & 0 deletions cmd/sessiond/reconnect_launch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"github.com/tokencanopy/rainier/protocol/runner"
"testing"
)

func TestReconnectRejectsChangedBootArtifactsBeforeRedemption(t *testing.T) {
for _, field := range []string{"command", "repository", "git", "setup", "proxy", "agent-manifest"} {
t.Run(field, func(t *testing.T) {
b, ch := reconnectFixture(t)
guest, host, ctx := reconnectPair(t)
done := make(chan error, 1)
go func() { done <- reBootstrap(ctx, guest, b); guest.Close() }()
accepted := acceptProof(t, ctx, host, b, ch, 1)
cfg := runner.BootConfig{Protocol: 1, GuestReconnect: 1, SessionID: "session-test", BootstrapToken: accepted.Token}
switch field {
case "command":
cfg.Cmd = []string{"different_test"}
case "repository":
cfg.Repos = []runner.RepoSpec{{Owner: "example", Name: "changed"}}
case "git":
cfg.GitAuthorName = "Changed Test"
case "setup":
cfg.Setup = "echo changed_test"
case "proxy":
cfg.ProxyURL = "http://proxy.example.invalid:80"
case "agent-manifest":
cfg.Env = map[string]string{"RAINIER_AGENTS_B64": "changed_test"}
}
sendReconnectConfig(t, ctx, host, cfg)
if raw, err := host.Read(ctx); err == nil || len(raw) != 0 {
t.Fatal("changed launch artifacts reached token redemption")
}
if err := <-done; err != errGuestReconnect {
t.Fatal("changed launch artifacts accepted")
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/sessiond/reconnect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func reconnectFixture(t *testing.T) (*bootstrapper, runner.GuestReconnectChallen
if err != nil {
t.Fatal(err)
}
return &bootstrapper{guest: &guestReconnectIdentity{session: "session-test", boot: "boot-test", key: key, enrolled: true}}, runner.GuestReconnectChallenge{Protocol: 1, SessionID: "session-test", BootEpoch: "boot-test", HostIncarnation: "host-test", AttemptID: "attempt-test", PlacementGeneration: 1, Challenge: base64.RawURLEncoding.EncodeToString(make([]byte, 32))}
return &bootstrapper{guest: &guestReconnectIdentity{session: "session-test", boot: "boot-test", key: key, enrolled: true, launch: guestLaunchIdentity(runner.BootConfig{Protocol: 1, GuestReconnect: 1, SessionID: "session-test"})}}, runner.GuestReconnectChallenge{Protocol: 1, SessionID: "session-test", BootEpoch: "boot-test", HostIncarnation: "host-test", AttemptID: "attempt-test", PlacementGeneration: 1, Challenge: base64.RawURLEncoding.EncodeToString(make([]byte, 32))}
}
func writeReconnect(t *testing.T, ctx context.Context, c relay.Conn, kind string, v any) {
t.Helper()
Expand Down
7 changes: 4 additions & 3 deletions control/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ func TestSessionStateVocabulary(t *testing.T) {
values := map[control.SessionState]string{
control.StateQueued: "queued",
control.StateCreating: "creating",
control.StateResuming: "resuming",
control.StateRunning: "running",
control.StateSuspendedWarm: "suspended_warm",
control.StateSuspendedCold: "suspended_cold",
Expand All @@ -200,7 +201,7 @@ func TestSessionStateVocabulary(t *testing.T) {
}

terminalStates := map[control.SessionState]bool{
control.StateQueued: false, control.StateCreating: false, control.StateRunning: false,
control.StateQueued: false, control.StateCreating: false, control.StateResuming: false, control.StateRunning: false,
control.StateSuspendedWarm: false, control.StateSuspendedCold: false,
control.StateCanceled: true, control.StateFailed: true,
control.StateDead: true, control.StateDestroyed: true,
Expand All @@ -213,7 +214,7 @@ func TestSessionStateVocabulary(t *testing.T) {

slotStates := map[control.SessionState]bool{
control.StateQueued: false,
control.StateCreating: true, control.StateRunning: true,
control.StateCreating: true, control.StateResuming: true, control.StateRunning: true,
control.StateSuspendedWarm: true, control.StateSuspendedCold: false,
control.StateCanceled: false, control.StateFailed: false,
control.StateDead: false, control.StateDestroyed: false,
Expand All @@ -225,7 +226,7 @@ func TestSessionStateVocabulary(t *testing.T) {
}

wantOrder := []control.SessionState{
control.StateQueued, control.StateCreating, control.StateRunning,
control.StateQueued, control.StateCreating, control.StateResuming, control.StateRunning,
control.StateSuspendedWarm, control.StateSuspendedCold,
}
if len(control.NonTerminal) != len(wantOrder) {
Expand Down
Loading
Loading