Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
509 changes: 509 additions & 0 deletions cmd/sessiond/bootconfig.go

Large diffs are not rendered by default.

633 changes: 633 additions & 0 deletions cmd/sessiond/bootconfig_test.go

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions cmd/sessiond/coldsuspend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"encoding/json"
"os"
"testing"

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

// The cold half of the suspend handshake, wired the way main wires it, so
// what is exercised is the frame's whole journey — OnControl's kind
// dispatch, the `cold` flag, the handler, and the two answers — rather than
// quiesceCold called directly.
func coldWired(execs execKiller, boots *bootstrapper) (*rpcDispatcher, *recordingSender) {
d := newRPCDispatcher()
sender := &recordingSender{}
d.online(sender)
d.RegisterEventHandler(relay.KindSuspending, func(ev relay.ControlEvent) {
if ev.Cold {
quiesceCold(execs, d, nil, boots, ev.ID)
return
}
quiesceExecs(execs, d, ev.ID)
})
return d, sender
}

func suspendingFrame(t *testing.T, cold bool, nonce uint64) []byte {
t.Helper()
b, err := json.Marshal(relay.ControlEvent{Kind: relay.KindSuspending, ID: nonce, Cold: cold})
if err != nil {
t.Fatal(err)
}
return b
}

// TestAColdSuspendForgetsTheDeliveredSecrets is §4.4 from inside the guest:
// a cold notice is not a freeze, so the sandbox ends its execs AND forgets
// every secret the bootstrap exchange delivered before it answers ready.
func TestAColdSuspendForgetsTheDeliveredSecrets(t *testing.T) {
cleanEnv(t)
boots := &bootstrapper{}
if err := applyBootConfig(
runner.BootConfig{Protocol: runner.SessionBootstrapProtocolVersion, SessionID: "sess_example"},
map[string]string{"DEPLOY_KEY": "value_example"}); err != nil {
t.Fatal(err)
}
boots.remember([]string{"DEPLOY_KEY"})

execs := &recordingExecs{}
d, sender := coldWired(execs, boots)
d.OnControl(suspendingFrame(t, true, 91))

if _, set := os.LookupEnv("DEPLOY_KEY"); set {
t.Error("a cold suspend left a delivered secret in this process's environment")
}
if calls, budget := execs.state(); calls != 1 || budget != execQuiesceBudget {
t.Fatalf("the cold suspend killed the execs %d time(s) with budget %s", calls, budget)
}
got := sender.events()
if len(got) != 2 || got[0].Kind != relay.KindSuspendAck || got[1].Kind != relay.KindSuspendReady {
t.Fatalf("the sandbox answered %+v, want an ack and then a ready", got)
}
for _, ev := range got {
if ev.ID != 91 {
t.Fatalf("an answer carries nonce %d, want the notice's 91", ev.ID)
}
}
}

// TestAWarmSuspendKeepsTheDeliveredSecrets is the other side of the same
// flag, and the reason it exists: a freeze is not the end of this VM, so the
// session comes back with the same process, the same agent, and the same
// environment. Forgetting there would break a warm resume for nothing.
func TestAWarmSuspendKeepsTheDeliveredSecrets(t *testing.T) {
cleanEnv(t)
boots := &bootstrapper{}
if err := applyBootConfig(
runner.BootConfig{Protocol: runner.SessionBootstrapProtocolVersion, SessionID: "sess_example"},
map[string]string{"DEPLOY_KEY": "value_example"}); err != nil {
t.Fatal(err)
}
boots.remember([]string{"DEPLOY_KEY"})

execs := &recordingExecs{}
d, sender := coldWired(execs, boots)
d.OnControl(suspendingFrame(t, false, 92))

if os.Getenv("DEPLOY_KEY") != "value_example" {
t.Error("a warm suspend forgot a delivered secret the resumed session still needs")
}
if got := sender.events(); len(got) != 2 {
t.Fatalf("the sandbox answered %+v, want an ack and then a ready", got)
}
}
59 changes: 50 additions & 9 deletions cmd/sessiond/gitchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,28 @@ import (
// The stage names. They are wire-visible: they travel in ControlEvent.Stage
// and controld composes a session's error text out of them.
const (
stageSetup = "setup"
stageClone = "clone"
stageAgents = "agents"
stageInit = "init"
// stageSecrets is FIRST in the chain and exists only to fail. A microVM
// session whose environment declared secrets it could not be given is
// not the environment it was created from, so the agent must not start —
// and the way a boot chain refuses to start the agent is a stage that
// exits non-zero, which the watcher then reports as stage_failed with a
// tail a person can read. See bootconfig.go.
stageSecrets = "secrets"
stageSetup = "setup"
stageClone = "clone"
stageAgents = "agents"
stageInit = "init"
)

// The chain's files, all in the session's own .rainier directory beside the
// setup script Plan 4 put there.
const (
clonesScriptName = "clones.sh"
cloneRCName = "clone.rc"
agentsScriptName = "agents.sh"
agentsRCName = "agents.rc"
secretsScriptName = "secrets.sh"
secretsRCName = "secrets.rc"
clonesScriptName = "clones.sh"
cloneRCName = "clone.rc"
agentsScriptName = "agents.sh"
agentsRCName = "agents.rc"
// agentsDoneName is the marker sessiond writes when the agent homes are as
// ready as they are going to get, and the file the agents stage waits for.
// It lives beside the rc files, on the same persistent volume, which is why
Expand Down Expand Up @@ -147,6 +156,20 @@ type bootEnv struct {

GitAuthorName string
GitAuthorEmail string

// SecretsFailure is the one field that does not come from the
// environment block: it is set by the microVM boot when the values this
// session's create promised could not be delivered (bootconfig.go).
//
// It becomes the first stage of the chain, and that stage only fails.
// Putting it here rather than handling it in main is what makes it a
// STAGE — reported as stage_failed with a tail, with the agent never
// exec'd — through exactly the machinery a failed setup or a failed
// clone already goes through, rather than through a second path that
// would have to be kept in step with it.
//
// It names a count and never a value; see undeliveredSecrets.
SecretsFailure string
}

// bootEnvFromOS reads the variables the driver injects (internal/driver
Expand Down Expand Up @@ -175,7 +198,8 @@ func bootEnvFromOS() bootEnv {
// design's intent: a home nobody waited for is a login that lands after the
// agent already read its configuration.
func (e bootEnv) any() bool {
return e.SetupB64 != "" || e.ReposB64 != "" || e.InitB64 != "" || e.AgentsB64 != ""
return e.SetupB64 != "" || e.ReposB64 != "" || e.InitB64 != "" || e.AgentsB64 != "" ||
e.SecretsFailure != ""
}

// git reports whether git will run in this session — the clone stage, or an
Expand Down Expand Up @@ -337,6 +361,23 @@ func prepareBoot(dir, root string, env bootEnv) ([]bootStage, []envVar, error) {
}

var stages []bootStage
// First, and only when the microVM boot could not get what this
// session's create promised it. Everything after it is skipped by the
// chain's own `exit $rc`, which is the point: an environment missing its
// credentials must not run a setup script, clone a repository, or start
// an agent that will fail at whatever it reaches for first.
if env.SecretsFailure != "" {
if err := writeStageScript(dir, secretsScriptName, secretsRCName,
[]byte(failingScript(env.SecretsFailure))); err != nil {
return nil, nil, err
}
stages = append(stages, bootStage{
Name: stageSecrets,
ScriptPath: dir + "/" + secretsScriptName,
RCPath: dir + "/" + secretsRCName,
Timeout: stageTimeout(""),
})
}
if env.SetupB64 != "" {
if err := prepareSetup(dir, env.SetupB64); err != nil {
return nil, nil, err
Expand Down
Loading
Loading