carlos / platform Public

settings-4 task 5b: the exec backend runs one recorded staged identity, flat and nested, for the run and seed units

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a9fe69fa24fa620ee34e9e6b0e2537d6331c8286 parent d02eb52
9 files changed, +901 −56
  • internal/activator/backend_exec.go +131 −21
  • internal/activator/containment.go +36 −3
  • internal/activator/control.go +15 −0
  • internal/activator/identity.go +66 −0
  • internal/activator/identity_test.go +447 −0
  • internal/instancectl/client.go +39 −9
  • internal/instancectl/server.go +29 −12
  • internal/instancectl/stagedexe.go +6 −4
  • internal/instancectl/stagedexe_linux_test.go +132 −7
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 34592be3c..cd4aa9b93 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -780,11 +780,30 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error {
p := b.pathsFor(rt)
switch p.layout {
case LayoutNested:
- return b.seedUnit(ctx, rt)
+ return b.seedUnit(ctx, rt, nil)
case LayoutFenced, LayoutConflict:
return fmt.Errorf("%s: refusing to seed: %s", rt.Host, p.reason)
}
- return b.seedChild(ctx, rt)
+ return b.seedChild(ctx, rt, b.liveLink(rt.Host))
+}
+
+// SeedExe is Seed on the identity a wake recorded (settings spec §4.2):
+// id.StagedPath is checked against id.Digest first, and a copy that
+// changed seeds nothing (never the live link instead). A flat or foreign
+// row execs that file by path. A nested row asks root to run the seed unit
+// on it, which root validates and binds in place of the live link.
+func (b *ExecBackend) SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error {
+ if err := release.VerifyStaged(id); err != nil {
+ return fmt.Errorf("%s: not seeded with %s: %w", rt.Host, id.Version, err)
+ }
+ p := b.pathsFor(rt)
+ switch p.layout {
+ case LayoutNested:
+ return b.seedUnit(ctx, rt, stagedExeOf(id))
+ case LayoutFenced, LayoutConflict:
+ return fmt.Errorf("%s: refusing to seed: %s", rt.Host, p.reason)
+ }
+ return b.seedChild(ctx, rt, id.StagedPath)
}
// seedArgv parses rt.Seed's JSON argv array and appends the --db flag, the
@@ -815,7 +834,10 @@ func removeSeedDB(dbPath string) {
}
}
-func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
+// seedChild is Seed's flat body. exe is the file the seed execs: the live
+// link (Seed), which is materialized first if it is missing, or a staged
+// path SeedExe verified, which is exec'd by path whatever the link names.
+func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route, exe string) error {
host := rt.Host
paths := b.pathsFor(rt)
argv, err := seedArgv(rt, paths.dbPath)
@@ -825,8 +847,10 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
if argv == nil {
return nil // defensive: doWake only calls us for a non-empty seed
}
- if err := b.ensureLiveLink(ctx, host); err != nil {
- return err
+ if exe == b.liveLink(host) {
+ if err := b.ensureLiveLink(ctx, host); err != nil {
+ return err
+ }
}
// Mint before the env is read, exactly as Start does — a seed is the FIRST
// thing a fresh instance runs, so this is usually where the token comes
@@ -850,7 +874,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
ctx, cancel := context.WithTimeout(ctx, seedTimeout)
defer cancel()
var stderr capBuffer
- log.Printf("activator: seeding %s: %s %s", host, b.liveLink(host), strings.Join(argv, " "))
+ log.Printf("activator: seeding %s: %s %s", host, exe, strings.Join(argv, " "))
// The seed shares the instance's carlos-exec@<host> cgroup: first-boot
// cost is that instance's work. No CleanupHost here — the instance spawn
// reuses the dir moments later, and its exit path (or the next agent
@@ -860,7 +884,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
return perr
}
cmd, settle, err := b.spawnInCgroupAt(rt, child, false, func() *exec.Cmd {
- cmd := exec.CommandContext(ctx, b.liveLink(host), argv...)
+ cmd := exec.CommandContext(ctx, exe, argv...)
// The control-channel vars are agent-owned in BOTH directions here too
// (Start's discipline, see withoutVars), and here they are always
// STRIPPED: the seed gets no control listener of its own, so fileEnv is
@@ -988,13 +1012,26 @@ func (c *capBuffer) String() string {
// this call itself caused, run through the root instance-control channel
// instead of a direct exec (design §6.2). Argv and env come from the same
// seedArgv and withoutVars calls seedChild uses, so the two cannot drift.
-func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error {
+//
+// A nil exe seeds on the live link (Seed). A staged exe is the build the
+// wake recorded (SeedExe): root validates it and binds the seed unit's app
+// mount to it, so the live link need not exist and a repoint of it
+// changes nothing. A control channel that cannot name it refuses the seed.
+func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe) error {
host := rt.Host
if err := b.nestedGate(rt); err != nil {
return err
}
- if err := b.ensureLiveLink(ctx, host); err != nil {
- return err
+ var xc StagedExeControl
+ if exe == nil {
+ if err := b.ensureLiveLink(ctx, host); err != nil {
+ return err
+ }
+ } else {
+ var err error
+ if xc, err = b.stagedExeControl(); err != nil {
+ return fmt.Errorf("%s: not seeded with %s: %w", host, exe.Version, err)
+ }
}
p, _, logs, fileEnv, err := b.prepare(rt, instancectl.RoleSeed)
if err != nil {
@@ -1028,8 +1065,19 @@ func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error {
// client's own timeout but harmless, and keeps this call visibly
// bounded without second-guessing how long a seed may take.
sctx, cancel := context.WithTimeout(ctx, instancectl.DeadlineSeed)
- res, err := b.Control.Seed(sctx, host, b.tier(rt).MemoryMax)
+ var res instancectl.SeedResponse
+ if exe != nil {
+ res, err = xc.SeedExe(sctx, host, b.tier(rt).MemoryMax, *exe)
+ } else {
+ res, err = b.Control.Seed(sctx, host, b.tier(rt).MemoryMax)
+ }
cancel()
+ if errors.Is(err, instancectl.ErrStagedExe) {
+ // Root refused the recorded build before it laid out or started
+ // anything (403), so no seed ran: nothing to stop, and the
+ // database is not the seed's work.
+ return fmt.Errorf("seed for %s: %w", host, err)
+ }
if err != nil {
return b.seedUnitFailed(ctx, rt, dbPath, seedOwnsDB, fmt.Errorf("seed for %s: %w", host, err))
}
@@ -1193,6 +1241,24 @@ func (b *ExecBackend) ensureLiveLink(ctx context.Context, host string) error {
return fmt.Errorf("no live binary for %s (%s missing and no Materialize hook)", host, bin)
}
+// LiveIdentity is the one file a cold wake will run (settings spec §4.2):
+// the live link, materialized first if it is missing (ensureLiveLink, so
+// the Materialize hook and its root-stage relay run exactly as a wake's
+// spawn would run them), then resolved ONCE into the file at the end of
+// its chain and that file's digest, as StartWorkerStaged does. Version is
+// "" for a link outside the version store; the caller decides whether that
+// may run (Service.recordBuild refuses it while a build state is kept).
+func (b *ExecBackend) LiveIdentity(ctx context.Context, rt registry.Route) (release.StagedIdentity, error) {
+ if err := b.ensureLiveLink(ctx, rt.Host); err != nil {
+ return release.StagedIdentity{}, err
+ }
+ id, err := release.ResolveLive(b.OptDir, rt.Host)
+ if err != nil {
+ return release.StagedIdentity{}, fmt.Errorf("%s: %w", rt.Host, err)
+ }
+ return id, nil
+}
+
// Start dispatches a wake by layout (design §8.1): a nested row starts as a
// unit through the root instance-control channel, a fenced or conflicting
// row refuses outright, and a flat or foreign row runs today's exec child.
@@ -1200,11 +1266,11 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
p := b.pathsFor(rt)
switch p.layout {
case LayoutNested:
- return b.startUnitAt(ctx, rt, rt.Socket, 0)
+ return b.startUnitAt(ctx, rt, rt.Socket, 0, nil)
case LayoutFenced, LayoutConflict:
return nil, "", fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason)
}
- return b.startChildAt(ctx, rt, rt.Socket, 0)
+ return b.startChildAt(ctx, rt, rt.Socket, 0, "")
}
// StartGen is Start for generation n (zero-downtime spec §7.1). perGen false
@@ -1220,6 +1286,39 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
// whose counter started at 1 like this one's). A sibling generation's path
// is never touched.
func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) {
+ return b.startGenFrom(ctx, rt, n, perGen, nil)
+}
+
+// StartGenExe is StartGen on the identity a wake recorded (settings spec
+// §4.2): the same generation, socket and tier, but the process runs
+// id.StagedPath, checked against id.Digest first. A copy that changed is a
+// failed start, and nothing runs (never the live link instead). A flat or
+// foreign row execs the file by path. A nested row asks root to bind it in
+// place of the live link: the original run unit while GenContainment is
+// off (generation 0), generation n's unit while it is on. The handle names
+// id's build, path, digest and demand, so a sidecar can bind the same file.
+func (b *ExecBackend) StartGenExe(ctx context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error) {
+ if err := release.VerifyStaged(id); err != nil {
+ return nil, Gen{}, fmt.Errorf("%s: not started on %s: %w", rt.Host, id.Version, err)
+ }
+ proc, g, err := b.startGenFrom(ctx, rt, n, perGen, &id)
+ if err != nil {
+ return proc, g, err
+ }
+ g.Build, g.StagedPath, g.Digest, g.Demand = id.Version, id.StagedPath, id.Digest, id.Demand
+ return proc, g, nil
+}
+
+// startGenFrom is StartGen's body. A nil id is the live link (StartGen); a
+// non-nil one is the verified file StartGenExe names.
+func (b *ExecBackend) startGenFrom(ctx context.Context, rt registry.Route, n uint64, perGen bool, id *release.StagedIdentity) (Proc, Gen, error) {
+ var (
+ flatExe string
+ unitExe *instancectl.StagedExe
+ )
+ if id != nil {
+ flatExe, unitExe = id.StagedPath, stagedExeOf(*id)
+ }
p := b.pathsFor(rt)
sock := rt.Socket
if perGen {
@@ -1238,7 +1337,7 @@ func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64,
switch p.layout {
case LayoutNested:
// prepareAt removes the stale file at sock for the nested layout.
- proc, ver, err = b.startUnitAt(ctx, rt, sock, n)
+ proc, ver, err = b.startUnitAt(ctx, rt, sock, n, unitExe)
case LayoutFenced, LayoutConflict:
return nil, Gen{}, fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason)
default:
@@ -1254,7 +1353,7 @@ func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64,
return nil, Gen{}, fmt.Errorf("%s: generation socket: %w", rt.Host, err)
}
}
- proc, ver, err = b.startChildAt(ctx, rt, sock, n)
+ proc, ver, err = b.startChildAt(ctx, rt, sock, n, flatExe)
}
g := Gen{N: n, Socket: sock, Control: sock + ".control"}
if err != nil {
@@ -1295,11 +1394,21 @@ func clearOwnGenSocket(sock string) error {
// generation socket (StartGen). n is the generation this spawn is (StartGen's
// n; 0 from Start). It only places the spawn's cgroup, and only under
// GenContainment (placeFor).
-func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) {
- p, spawnVer, err := b.spawnChildFrom(ctx, rt, sock, n, "", cgroup.Limit{})
+//
+// exe is "" for the live link, or the staged file a wake recorded
+// (StartGenExe). spawnChildFrom leaves a staged spawn's cache purge to the
+// swap's switch, but a wake's staged spawn is the host's only process, so
+// its purge happens here, at the same moment, before the health wait.
+func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock string, n uint64, exe string) (Proc, string, error) {
+ p, spawnVer, err := b.spawnChildFrom(ctx, rt, sock, n, exe, cgroup.Limit{})
if err != nil {
return p, "", err
}
+ if exe != "" && b.Cache != nil {
+ if purged := b.Cache.PurgeHost(rt.Host); purged > 0 {
+ log.Printf("activator: purged %d cached response(s) for %s on spawn", purged, rt.Host)
+ }
+ }
if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {
stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
defer cancel()
@@ -1742,10 +1851,11 @@ func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock
// composition and health gate as startChild, but the process itself is
// started by the root instance-control channel from a launch file rather
// than exec'd directly. The agent has no privilege to enter the tenant's
-// namespace, only the control service does. It is spawnUnitFrom on the live
-// link, then the health wait.
-func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) {
- p, spawnVer, err := b.spawnUnitFrom(ctx, rt, sock, n, nil, cgroup.Limit{})
+// namespace, only the control service does. It is spawnUnitFrom, then the
+// health wait. exe is nil for the live link, or the staged file a wake
+// recorded (StartGenExe), which root binds in place of the link.
+func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64, exe *instancectl.StagedExe) (Proc, string, error) {
+ p, spawnVer, err := b.spawnUnitFrom(ctx, rt, sock, n, exe, cgroup.Limit{})
if err != nil {
return p, "", err
}
diff --git a/internal/activator/containment.go b/internal/activator/containment.go
index fdae43c47..b948de49f 100644
--- a/internal/activator/containment.go
+++ b/internal/activator/containment.go
@@ -164,6 +164,24 @@ func (b *ExecBackend) genControl() (GenControl, error) {
return gc, nil
}
+// errNoStagedExeControl refuses a start or seed of an original template
+// that names a staged build when the control channel cannot name one.
+// Binding the live link instead would run a build nobody recorded.
+var errNoStagedExeControl = errors.New("activator: the instance control channel cannot name an executable for this unit")
+
+// stagedExeControl is Control's original-template executable half, or
+// errNoStagedExeControl.
+func (b *ExecBackend) stagedExeControl() (StagedExeControl, error) {
+ if b.Control == nil {
+ return nil, errNoStagedExeControl
+ }
+ xc, ok := b.Control.(StagedExeControl)
+ if !ok {
+ return nil, errNoStagedExeControl
+ }
+ return xc, nil
+}
+
// unitGen is the generation a nested run unit is started as: 0 (the
// original template) while GenContainment is off, n otherwise. n of 0
// with containment on is refused.
@@ -179,11 +197,26 @@ func (b *ExecBackend) unitGen(n uint64) (uint64, error) {
// startRole starts one role's unit and returns its invocation: the
// original template through Control, a generation through GenControl. exe
-// names a staged build for a generation (nil: the live link, plan 2's
-// StartGen), and tier is the generation's own (zero: b.tier(rt)).
+// names a staged build (nil: the live link, plan 2's StartGen), and tier
+// is that build's own (zero: b.tier(rt)).
+//
+// At generation 0 a named exe is the build a cold wake recorded (settings
+// spec §4.2), started through StagedExeControl. A channel without it
+// refuses the start: dropping exe would bind the live link, a build
+// nobody recorded.
func (b *ExecBackend) startRole(ctx context.Context, rt registry.Route, role instancectl.Role, gen uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (string, error) {
if gen == 0 {
- return b.Control.Start(ctx, rt.Host, role, b.tier(rt).MemoryMax)
+ if exe == nil {
+ return b.Control.Start(ctx, rt.Host, role, b.tier(rt).MemoryMax)
+ }
+ xc, err := b.stagedExeControl()
+ if err != nil {
+ return "", fmt.Errorf("%s: not started on %s: %w", rt.Host, exe.Version, err)
+ }
+ if tier == (cgroup.Limit{}) {
+ tier = b.tier(rt)
+ }
+ return xc.StartExe(ctx, rt.Host, role, tier.MemoryMax, *exe)
}
gc, err := b.genControl()
if err != nil {
diff --git a/internal/activator/control.go b/internal/activator/control.go
index 823b8d9ba..48a4b79a9 100644
--- a/internal/activator/control.go
+++ b/internal/activator/control.go
@@ -42,6 +42,21 @@ type GenControl interface {
var _ GenControl = (*instancectl.Client)(nil)
+// StagedExeControl is the original templates' half of naming an
+// executable (settings spec §4.2): the run unit at generation 0 and the
+// seed unit, started on a staged build root validates and binds in place
+// of the live link. A cold wake runs the build it recorded through it,
+// with generations on or off. It is separate from Control and GenControl
+// so a Control without it (a fake, an older client) still satisfies both;
+// the backend asks for it only when it has a file to name, and refuses
+// the start without it (stagedExeControl), never binding the link instead.
+type StagedExeControl interface {
+ StartExe(ctx context.Context, host string, role instancectl.Role, memoryMax int64, x instancectl.StagedExe) (string, error)
+ SeedExe(ctx context.Context, host string, memoryMax int64, x instancectl.StagedExe) (instancectl.SeedResponse, error)
+}
+
+var _ StagedExeControl = (*instancectl.Client)(nil)
+
// UnitState answers `systemctl show -p ActiveState,InvocationID <unit>`,
// which needs no privilege. An unloaded unit answers inactive with an
// empty invocation.
diff --git a/internal/activator/identity.go b/internal/activator/identity.go
new file mode 100644
index 000000000..d400d0e4d
--- /dev/null
+++ b/internal/activator/identity.go
@@ -0,0 +1,66 @@
+package activator
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// identityBackend is the exec backend's identity-pinned spawn and seed
+// (settings spec §4.2, Astra I1 #5). A cold wake captures ONE staged
+// identity (LiveIdentity), records it, and then seeds and spawns exactly
+// that file by path, verified against its digest. Neither call execs
+// live/<host>, so root repointing the link between the record and the exec
+// cannot make a different build touch the instance's data.
+type identityBackend interface {
+ // LiveIdentity materializes host's live link if it is missing (the
+ // Materialize hook and its root-stage relay), then resolves it ONCE
+ // into the file it names and that file's digest. Version is "" for a
+ // link outside the version store; the caller decides what that means.
+ LiveIdentity(ctx context.Context, rt registry.Route) (release.StagedIdentity, error)
+ // StartGenExe is StartGen (generation n, perGen choosing the socket)
+ // with the executable named: id.StagedPath, verified against id.Digest
+ // first. A copy that changed is a failed start, never a fallback to the
+ // link. The Gen it returns carries id's build, path, digest and demand,
+ // so a later sidecar binds the same file.
+ StartGenExe(ctx context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error)
+ // SeedExe is Seed with the executable named, verified the same way.
+ SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error
+}
+
+var _ identityBackend = (*ExecBackend)(nil)
+
+// identityPicker is a composed backend that answers per route.
+type identityPicker interface {
+ identityFor(rt registry.Route) identityBackend
+}
+
+// identityFor is the backend that can pin an executable for rt, or nil
+// (rt then keeps today's Start and Seed, and nothing is recorded for it).
+// Production's SplitBackend serves unit rows too, so a composed backend
+// answers per route, never by a type assertion on itself alone.
+func identityFor(b Backend, rt registry.Route) identityBackend {
+ if p, ok := b.(identityPicker); ok {
+ return p.identityFor(rt)
+ }
+ ib, _ := b.(identityBackend)
+ return ib
+}
+
+// identityFor is the exec half for an exec row and nil for a unit row:
+// systemd execs a unit row's live link itself, so nothing here can pin its
+// file. SplitBackend does not implement identityBackend itself.
+func (b *SplitBackend) identityFor(rt registry.Route) identityBackend {
+ if rt.Backing == registry.BackingUnit {
+ return nil
+ }
+ ib, _ := b.pick(rt).(identityBackend)
+ return ib
+}
+
+// stagedExeOf is id as root's control channel names an executable.
+func stagedExeOf(id release.StagedIdentity) *instancectl.StagedExe {
+ return &instancectl.StagedExe{Path: id.StagedPath, Version: id.Version, Digest: id.Digest}
+}
diff --git a/internal/activator/identity_test.go b/internal/activator/identity_test.go
new file mode 100644
index 000000000..1b07a5f95
--- /dev/null
+++ b/internal/activator/identity_test.go
@@ -0,0 +1,447 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// stageVersion writes <OptDir>/versions/<ver>/app with body and points
+// host's live link at it. Each version's body differs, so each has its own
+// digest.
+func stageVersion(t *testing.T, b *ExecBackend, host, ver, body string) string {
+ t.Helper()
+ bin := filepath.Join(b.OptDir, "versions", ver, "app")
+ if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\n# "+ver+"\n"+body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ repointLive(t, b, host, bin)
+ return bin
+}
+
+// repointLive is root's repoint: live/<host> names target from now on.
+func repointLive(t *testing.T, b *ExecBackend, host, target string) {
+ t.Helper()
+ if err := os.MkdirAll(b.liveDir(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ tmp := b.liveLink(host) + ".tmp"
+ os.Remove(tmp)
+ if err := os.Symlink(target, tmp); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(tmp, b.liveLink(host)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// servingBody is a staged build that records the path it was exec'd by
+// ($0) in marker, then becomes the test binary's fake app (TestMain), so it
+// binds --socket and answers the health wait.
+func servingBody(t *testing.T, marker string) string {
+ t.Helper()
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("ACTIVATOR_FAKE_APP", "1")
+ return "printf '%s' \"$0\" > '" + marker + "'\nexec '" + self + "' \"$@\"\n"
+}
+
+// Settings spec §4.2: the identity is captured per route. A unit row on a
+// SplitBackend has none, whatever its backend could do; an exec row gets
+// the exec half. A bare ExecBackend answers for itself, and a backend
+// without the capability answers nil.
+func TestIdentityIsPerRoute(t *testing.T) {
+ exec := testExecBackend(t)
+ // The unit half is an ExecBackend too, so a nil for the unit row is the
+ // row's answer, not an accident of its backend's type.
+ split := &SplitBackend{Exec: exec, Unit: testExecBackend(t)}
+ unitRow := registry.Route{Host: "u.example.com", Backing: registry.BackingUnit}
+ execRow := registry.Route{Host: "e.example.com"}
+ if ib := identityFor(split, unitRow); ib != nil {
+ t.Fatalf("a unit row got an identity backend: %T", ib)
+ }
+ if ib := identityFor(split, execRow); ib != identityBackend(exec) {
+ t.Fatalf("an exec row got %v, want the exec half", ib)
+ }
+ if ib := identityFor(exec, execRow); ib != identityBackend(exec) {
+ t.Fatalf("a bare ExecBackend answered %v for itself", ib)
+ }
+ if ib := identityFor(&fakeBackend{}, execRow); ib != nil {
+ t.Fatalf("a backend without the capability answered %T", ib)
+ }
+}
+
+// A cold default instance has no live link until the Materialize hook
+// stages one. LiveIdentity runs the hook first and captures what it
+// materialized, read once.
+func TestLiveIdentityMaterializesFirst(t *testing.T) {
+ b := testExecBackend(t)
+ host := "cold.example.com"
+ var bin string
+ calls := 0
+ b.Materialize = func(_ context.Context, h string) error {
+ calls++
+ bin = stageVersion(t, b, h, "v1", "exit 0\n")
+ return nil
+ }
+ id, err := b.LiveIdentity(context.Background(), registry.Route{Host: host})
+ if err != nil {
+ t.Fatalf("LiveIdentity: %v", err)
+ }
+ want, _ := release.FileSHA256(bin)
+ if calls != 1 || id.Version != "v1" || id.StagedPath != bin || id.Digest != want {
+ t.Fatalf("materialized %d times, identity %+v; want v1 at %s (%s)", calls, id, bin, want)
+ }
+ // A link that exists is not materialized again.
+ if _, err := b.LiveIdentity(context.Background(), registry.Route{Host: host}); err != nil || calls != 1 {
+ t.Fatalf("second capture: %v, materialized %d times", err, calls)
+ }
+ // A materialize failure is the capture's failure.
+ b.Materialize = func(context.Context, string) error { return errors.New("controld: 502") }
+ if _, err := b.LiveIdentity(context.Background(), registry.Route{Host: "other.example.com"}); err == nil || !strings.Contains(err.Error(), "502") {
+ t.Fatalf("a failed materialize = %v", err)
+ }
+}
+
+// Flat, generations off: StartGenExe execs the recorded file by path on the
+// canonical socket, though root repointed the link after the capture. The
+// handle names that build, its path and digest, and the host's cache is
+// purged as on any wake. A copy whose bytes changed starts nothing.
+func TestStartGenExeFlatExecsTheRecordedFile(t *testing.T) {
+ requireUnixSockets(t)
+ b := testExecBackend(t)
+ b.RunDirOverride = shortRunDir(t)
+ cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+ b.Cache = cache
+ host := "flat.example.com"
+ marker := filepath.Join(t.TempDir(), "argv0")
+ a := stageVersion(t, b, host, "vA", servingBody(t, marker))
+ rt := flatGenRoute(b, host)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ id, err := b.LiveIdentity(ctx, rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stageVersion(t, b, host, "vB", servingBody(t, marker)) // root's repoint lands after the record
+
+ stale := edge.CacheKey{Host: host, Method: "GET", Path: "/"}
+ cache.Put(stale, "", &edge.CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("old"), StoredAt: time.Now(), TTL: time.Minute})
+
+ proc, g, err := b.StartGenExe(ctx, rt, 3, false, id)
+ if err != nil {
+ t.Fatalf("StartGenExe: %v", err)
+ }
+ defer proc.Stop(context.Background()) //nolint:errcheck
+ if got := waitForFile(t, marker, 2*time.Second); got != a {
+ t.Fatalf("execd %q, want the recorded %q", got, a)
+ }
+ want := Gen{N: 3, Socket: rt.Socket, Control: rt.Socket + ".control", Build: "vA", StagedPath: a, Digest: id.Digest}
+ if g != want {
+ t.Fatalf("handle = %+v, want %+v", g, want)
+ }
+ if _, ok := cache.GetFor(stale, httptest.NewRequest("GET", "http://"+host+"/", nil)); ok {
+ t.Fatal("the host's cache survived the spawn")
+ }
+ stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer stopCancel()
+ if err := proc.Stop(stopCtx); err != nil {
+ t.Fatal(err)
+ }
+
+ os.Remove(marker)
+ bad := id
+ bad.Digest = strings.Repeat("0", 64)
+ p, _, err := b.StartGenExe(ctx, rt, 4, false, bad)
+ if !errors.Is(err, release.ErrStagedCopyChanged) || p != nil {
+ t.Fatalf("a changed copy = %v, %v; want ErrStagedCopyChanged and no process", p, err)
+ }
+ time.Sleep(200 * time.Millisecond)
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatal("a refused identity ran something")
+ }
+}
+
+// Flat, generations on: the recorded file binds generation n's own socket,
+// as StartGen does.
+func TestStartGenExeFlatPerGenBindsTheGenerationSocket(t *testing.T) {
+ requireUnixSockets(t)
+ b := testExecBackend(t)
+ b.RunDirOverride = shortRunDir(t)
+ host := "pergen.example.com"
+ marker := filepath.Join(t.TempDir(), "argv0")
+ a := stageVersion(t, b, host, "vA", servingBody(t, marker))
+ rt := flatGenRoute(b, host)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ id, err := b.LiveIdentity(ctx, rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stageVersion(t, b, host, "vB", servingBody(t, marker))
+ proc, g, err := b.StartGenExe(ctx, rt, 9, true, id)
+ if err != nil {
+ t.Fatalf("StartGenExe: %v", err)
+ }
+ defer proc.Stop(context.Background()) //nolint:errcheck
+ own := genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, 9)
+ if g.Socket != own || g.Control != own+".control" || !isSocket(own) {
+ t.Fatalf("handle = %+v, want generation 9's bound socket %s", g, own)
+ }
+ if got := waitForFile(t, marker, 2*time.Second); got != a {
+ t.Fatalf("execd %q, want the recorded %q", got, a)
+ }
+ if _, err := os.Lstat(rt.Socket); !os.IsNotExist(err) {
+ t.Fatalf("the canonical path was bound with generations on (lstat: %v)", err)
+ }
+}
+
+// A foreign row keeps its canonical socket whatever perGen says, as
+// StartStaged and StartGen keep it, and execs the recorded file there.
+func TestStartGenExeForeignRunsOnTheCanonicalSocket(t *testing.T) {
+ requireUnixSockets(t)
+ b := testExecBackend(t)
+ host := "foreign.example.com"
+ marker := filepath.Join(t.TempDir(), "argv0")
+ a := stageVersion(t, b, host, "vA", servingBody(t, marker))
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"), Socket: tmpSock(t)}
+ if l, _ := b.Layout(rt); l != LayoutForeign {
+ t.Fatalf("fixture layout = %v, want LayoutForeign", l)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ id, err := b.LiveIdentity(ctx, rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stageVersion(t, b, host, "vB", servingBody(t, marker))
+ proc, g, err := b.StartGenExe(ctx, rt, 2, true, id)
+ if err != nil {
+ t.Fatalf("StartGenExe: %v", err)
+ }
+ defer proc.Stop(context.Background()) //nolint:errcheck
+ if g.Socket != rt.Socket || g.Build != "vA" {
+ t.Fatalf("handle = %+v, want vA on the canonical %s", g, rt.Socket)
+ }
+ if got := waitForFile(t, marker, 2*time.Second); got != a {
+ t.Fatalf("execd %q, want the recorded %q", got, a)
+ }
+}
+
+// Flat seed: SeedExe runs the recorded file, though the link moved after
+// the capture, and Seed still runs the link. A changed copy seeds nothing.
+func TestSeedExeFlatRunsTheRecordedFile(t *testing.T) {
+ b := testExecBackend(t)
+ host := "seedexe.example.com"
+ marker := filepath.Join(t.TempDir(), "argv0")
+ record := "printf '%s' \"$0\" > '" + marker + "'\n"
+ a := stageVersion(t, b, host, "vA", record)
+ rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}
+ id, err := b.LiveIdentity(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bPath := stageVersion(t, b, host, "vB", record)
+ if err := b.SeedExe(context.Background(), rt, id); err != nil {
+ t.Fatalf("SeedExe: %v", err)
+ }
+ if got := waitForFile(t, marker, time.Second); got != a {
+ t.Fatalf("seeded with %q, want the recorded %q", got, a)
+ }
+
+ os.Remove(marker)
+ bad := id
+ bad.Digest = strings.Repeat("0", 64)
+ if err := b.SeedExe(context.Background(), rt, bad); !errors.Is(err, release.ErrStagedCopyChanged) {
+ t.Fatalf("a changed copy = %v, want ErrStagedCopyChanged", err)
+ }
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatal("a refused identity seeded")
+ }
+
+ if err := b.Seed(context.Background(), rt); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+ if got := waitForFile(t, marker, time.Second); got != b.liveLink(host) {
+ t.Fatalf("Seed ran %q, want the live link (today's, which resolves to %s)", got, bPath)
+ }
+}
+
+// identityControl is a root control channel that can name an executable
+// for the original templates (StagedExeControl) and for a generation
+// (GenControl). Each verb is recorded with the file it named.
+type identityControl struct{ *fakeControl }
+
+func exeLine(verb, host, what string, x instancectl.StagedExe) string {
+ return fmt.Sprintf("%s %s %s %s %s %s", verb, host, what, x.Path, x.Version, x.Digest)
+}
+
+func (c identityControl) StartExe(_ context.Context, host string, role instancectl.Role, _ int64, x instancectl.StagedExe) (string, error) {
+ c.rec(exeLine("startexe", host, string(role), x))
+ if c.onStart != nil {
+ c.onStart()
+ }
+ return c.inv, c.startErr
+}
+func (c identityControl) SeedExe(_ context.Context, host string, _ int64, x instancectl.StagedExe) (instancectl.SeedResponse, error) {
+ c.rec(exeLine("seedexe", host, "seed", x))
+ return c.seed, nil
+}
+func (c identityControl) StartGen(_ context.Context, host string, n uint64, _ int64) (string, error) {
+ c.rec(fmt.Sprintf("startgen %s %d", host, n))
+ return "", errors.New("test: the live-link generation start was used")
+}
+func (c identityControl) StartGenExe(_ context.Context, host string, n uint64, _ int64, x instancectl.StagedExe) (string, error) {
+ c.rec(exeLine("startgenexe", host, fmt.Sprint(n), x))
+ if c.onStart != nil {
+ c.onStart()
+ }
+ return c.inv, c.startErr
+}
+func (c identityControl) StartSidecarExe(_ context.Context, host string, _ int64, x instancectl.StagedExe) (string, error) {
+ c.rec(exeLine("startsidecarexe", host, "sidecar", x))
+ return c.inv, nil
+}
+func (c identityControl) StopGen(_ context.Context, host string, n uint64, kill bool) error {
+ c.rec(fmt.Sprintf("stopgen %s %d kill=%v", host, n, kill))
+ return nil
+}
+func (c identityControl) ResetGen(context.Context, string, uint64) error { return nil }
+func (c identityControl) Parent(context.Context, string, int64) error { return nil }
+
+// noPlainVerbs fails when ctl saw a start or seed that names no executable:
+// the live link's layout.
+func noPlainVerbs(t *testing.T, ctl *fakeControl) {
+ t.Helper()
+ for _, line := range strings.Split(ctl.joined(), "\n") {
+ if strings.HasPrefix(line, "start ") || strings.HasPrefix(line, "seed ") || strings.HasPrefix(line, "startgen ") {
+ t.Errorf("a live-link verb was used: %q in\n%s", line, ctl.joined())
+ }
+ }
+}
+
+// Nested: StartGenExe asks root for the recorded file, by path, version and
+// digest, after root's repoint of the live link to B. With generations off
+// that is the original run unit (generation 0); with them on, generation n.
+// Never a plain Start or StartGen.
+func TestNestedStartExeNamesTheRecordedFile(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ gens bool
+ n uint64
+ want func(host string, x instancectl.StagedExe) string
+ }{
+ {"generation 0", false, 1, func(h string, x instancectl.StagedExe) string { return exeLine("startexe", h, "run", x) }},
+ {"generation n", true, 6, func(h string, x instancectl.StagedExe) string { return exeLine("startgenexe", h, "6", x) }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctl := &fakeControl{inv: "inv1"}
+ unit := &fakeUnit{}
+ b, rt := nestedBackend(t, ctl, unit)
+ b.Control = identityControl{ctl}
+ b.GenContainment = tc.gens
+ a := stageVersion(t, b, rt.Host, "vA", "exit 0\n")
+ id, err := b.LiveIdentity(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stageVersion(t, b, rt.Host, "vB", "exit 0\n")
+ sock := rt.Socket
+ if tc.gens {
+ sock = nestedGenSocketIn(b.pathsFor(rt).runDir, tc.n)
+ }
+ var stopServing func()
+ ctl.onStart = func() { stopServing = serveOK(t, sock) }
+ defer func() {
+ if stopServing != nil {
+ stopServing()
+ }
+ }()
+ unit.set("active", "inv1", true, true)
+ p, g, err := b.StartGenExe(context.Background(), rt, tc.n, tc.gens, id)
+ if err != nil {
+ t.Fatalf("StartGenExe: %v", err)
+ }
+ x := instancectl.StagedExe{Path: a, Version: "vA", Digest: id.Digest}
+ if !strings.Contains(ctl.joined(), tc.want(rt.Host, x)) {
+ t.Fatalf("root was asked:\n%s\nwant %q", ctl.joined(), tc.want(rt.Host, x))
+ }
+ noPlainVerbs(t, ctl)
+ if g.Build != "vA" || g.StagedPath != a || g.Digest != id.Digest || g.Socket != sock {
+ t.Fatalf("handle = %+v", g)
+ }
+ unit.set("inactive", "inv1", false, false)
+ waitFor(t, "the unit to exit", p.Exited)
+ })
+ }
+}
+
+// Nested seed: SeedExe asks root to run the seed unit on the recorded file,
+// not a plain Seed on the link root moved to B. A changed copy asks root
+// for nothing.
+func TestNestedSeedExeNamesTheRecordedFile(t *testing.T) {
+ ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "success", Status: "0", Code: "exited"}}
+ b, rt := nestedBackend(t, ctl, &fakeUnit{})
+ b.Control = identityControl{ctl}
+ rt.Seed = `["seed"]`
+ a := stageVersion(t, b, rt.Host, "vA", "exit 0\n")
+ id, err := b.LiveIdentity(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stageVersion(t, b, rt.Host, "vB", "exit 0\n")
+ if err := b.SeedExe(context.Background(), rt, id); err != nil {
+ t.Fatalf("SeedExe: %v", err)
+ }
+ x := instancectl.StagedExe{Path: a, Version: "vA", Digest: id.Digest}
+ if want := exeLine("seedexe", rt.Host, "seed", x); !strings.Contains(ctl.joined(), want) {
+ t.Fatalf("root was asked:\n%s\nwant %q", ctl.joined(), want)
+ }
+ noPlainVerbs(t, ctl)
+
+ ctl.mu.Lock()
+ ctl.calls = nil
+ ctl.mu.Unlock()
+ bad := id
+ bad.Digest = strings.Repeat("0", 64)
+ if err := b.SeedExe(context.Background(), rt, bad); !errors.Is(err, release.ErrStagedCopyChanged) {
+ t.Fatalf("a changed copy = %v, want ErrStagedCopyChanged", err)
+ }
+ if ctl.joined() != "" {
+ t.Fatalf("a refused identity reached root:\n%s", ctl.joined())
+ }
+}
+
+// A control channel that cannot name an executable for the original
+// template refuses a generation-0 start that names one. It never drops the
+// file and binds the live link instead.
+func TestStartRoleRefusesAnExeTheControlCannotName(t *testing.T) {
+ b := testExecBackend(t)
+ ctl := &fakeControl{inv: "inv"}
+ b.Control = ctl
+ x := instancectl.StagedExe{Path: "/opt/carlos/versions/v2/blog", Version: "v2", Digest: strings.Repeat("a", 64)}
+ if _, err := b.startRole(context.Background(), registry.Route{Host: "n.example.com"}, instancectl.RoleRun, 0, &x, cgroup.Limit{}); err == nil {
+ t.Fatal("a generation-0 start with an executable the control cannot name was accepted")
+ }
+ if ctl.joined() != "" {
+ t.Fatalf("root was asked:\n%s", ctl.joined())
+ }
+}
diff --git a/internal/instancectl/client.go b/internal/instancectl/client.go
index 3c2a9678b..57a4a5334 100644
--- a/internal/instancectl/client.go
+++ b/internal/instancectl/client.go
@@ -153,13 +153,38 @@ func (c *Client) Start(ctx context.Context, host string, role Role, memoryMax in
// Seed runs the seed unit to completion and returns its retained result.
func (c *Client) Seed(ctx context.Context, host string, memoryMax int64) (SeedResponse, error) {
+ return c.seedQuery(ctx, host, memoryMax, nil)
+}
+
+// SeedExe is Seed with the seed's executable named: the staged build the
+// wake recorded (settings spec §4.2), which root validates and binds in
+// place of the live link. A 403 is root's refusal, reported as
+// ErrStagedExe: nothing was laid out or run.
+func (c *Client) SeedExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (SeedResponse, error) {
+ return c.seedQuery(ctx, host, memoryMax, &x)
+}
+
+func (c *Client) seedQuery(ctx context.Context, host string, memoryMax int64, x *StagedExe) (SeedResponse, error) {
q := hostRole(host, "")
q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
+ if x != nil {
+ setStagedExe(q, *x)
+ }
var out SeedResponse
- _, _, err := c.do(ctx, DeadlineSeed, http.MethodPost, "/seed", q, &out)
+ status, _, err := c.do(ctx, DeadlineSeed, http.MethodPost, "/seed", q, &out)
+ if err != nil && status == http.StatusForbidden && x != nil {
+ return out, fmt.Errorf("%w: %v", ErrStagedExe, err)
+ }
return out, err
}
+// setStagedExe puts x on a start or seed query.
+func setStagedExe(q url.Values, x StagedExe) {
+ q.Set("exe", x.Path)
+ q.Set("version", x.Version)
+ q.Set("digest", x.Digest)
+}
+
func (c *Client) Stop(ctx context.Context, host string, role Role) error {
_, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/stop", hostRole(host, role), nil)
return err
@@ -191,12 +216,19 @@ func (c *Client) StartGenExe(ctx context.Context, host string, n uint64, memoryM
return c.startUnitQuery(ctx, genValues(host, n), memoryMax, &x)
}
-// StartSidecarExe is Start for the legacy sidecar role with its executable
-// named: the serving generation's staged build, which root validates and
-// mounts in place of the live link (zero-downtime spec §7.5). A 403 is
-// root's refusal, reported as ErrStagedExe.
+// StartExe is Start with the unit's executable named: a staged build root
+// validates and mounts in place of the live link, for the original
+// template's run unit (the build a cold wake recorded, settings spec §4.2)
+// or the legacy sidecar. A 403 is root's refusal, reported as
+// ErrStagedExe.
+func (c *Client) StartExe(ctx context.Context, host string, role Role, memoryMax int64, x StagedExe) (string, error) {
+ return c.startUnitQuery(ctx, hostRole(host, role), memoryMax, &x)
+}
+
+// StartSidecarExe is StartExe for the legacy sidecar role: the serving
+// generation's staged build (zero-downtime spec §7.5).
func (c *Client) StartSidecarExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (string, error) {
- return c.startUnitQuery(ctx, hostRole(host, RoleSidecar), memoryMax, &x)
+ return c.StartExe(ctx, host, RoleSidecar, memoryMax, x)
}
// startUnitQuery is /start for a generation or the sidecar, optionally with
@@ -206,9 +238,7 @@ func (c *Client) StartSidecarExe(ctx context.Context, host string, memoryMax int
func (c *Client) startUnitQuery(ctx context.Context, q url.Values, memoryMax int64, x *StagedExe) (string, error) {
q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
if x != nil {
- q.Set("exe", x.Path)
- q.Set("version", x.Version)
- q.Set("digest", x.Digest)
+ setStagedExe(q, *x)
}
var out StartResponse
status, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/start", q, &out)
diff --git a/internal/instancectl/server.go b/internal/instancectl/server.go
index 1571cd988..bfd6ba99f 100644
--- a/internal/instancectl/server.go
+++ b/internal/instancectl/server.go
@@ -305,7 +305,10 @@ type verbShape struct {
tier bool
gen bool
kill bool
- exe bool // a start verb: a generation's, or the legacy sidecar's, staged build
+ exe bool // a start or seed verb: the staged build its unit binds
+ // seed is the /seed verb, which names no role: its unit is the seed
+ // unit, which may bind a staged build (settings spec §4.2).
+ seed bool
}
func parseRequest(r *http.Request, v verbShape) (request, error) {
@@ -366,11 +369,14 @@ func parseRequest(r *http.Request, v verbShape) (request, error) {
return request{}, errors.New("this verb takes no staged executable")
}
if x := q.Get("exe"); x != "" {
- // A generation start, or the legacy sidecar (zero-downtime spec
- // §7.5: it runs its instance's serving artifact). The original
- // template's run unit keeps the live link.
- if req.gen == 0 && req.role != RoleSidecar {
- return request{}, errors.New("exe is only for a generation start or a sidecar")
+ // A generation start; the legacy sidecar (zero-downtime spec
+ // §7.5: it runs its instance's serving artifact); or the
+ // original template's run unit and the seed unit, which run
+ // the build a cold wake recorded (settings spec §4.2). Every
+ // one is validated before anything is laid out
+ // (refuseStagedExe).
+ if req.gen == 0 && !v.seed && req.role != RoleRun && req.role != RoleSidecar {
+ return request{}, errors.New("exe is only for a run, seed or sidecar unit, or a generation start")
}
req.exe = StagedExe{Path: x, Version: q.Get("version"), Digest: q.Get("digest")}
} else {
@@ -417,8 +423,9 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
}
ctx := r.Context()
unit := UnitName(req.role, req.host)
- // A sidecar naming its generation's staged build is validated before
- // anything is laid out, under the run unit's rule (validateStagedExe).
+ // A sidecar naming its generation's staged build, or a run unit naming
+ // the build its cold wake recorded, is validated before anything is
+ // laid out, under the generation rule (validateStagedExe).
if s.refuseStagedExe(w, req, unit) {
return
}
@@ -437,8 +444,10 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
// would put both generations under one tier's limit. Its restore is the
// swap owner's, so a populated slice is left alone, exactly as startGen
// does; a cold slice still gets the tier. A start without exe (flag
- // off) sets the tier as today.
- if req.exe.Path != "" && s.slicePopulated(req.host) {
+ // off) sets the tier as today. The exemption is the sidecar's alone: a
+ // run unit naming its recorded build starts a cold instance, and sets
+ // its tier as a run start always has.
+ if req.role == RoleSidecar && req.exe.Path != "" && s.slicePopulated(req.host) {
s.logf("instance-control: %s: slice populated, leaving its limit to the generation owner", req.host)
} else if err := s.setTier(ctx, req.host, req.tier); err != nil {
http.Error(w, "set-property: "+err.Error(), http.StatusBadGateway)
@@ -490,15 +499,23 @@ func parseShow(out string) map[string]string {
// fields RemainAfterExit retains), then stop, then wait for the cgroup
// to empty: a populated cgroup after the wait is still reported as a 502
// naming the unit.
+//
+// A seed may name its executable (settings spec §4.2): the staged build
+// the wake recorded. Root validates it as it does a generation's (403 on
+// a refusal, before anything is laid out or started) and binds the host's
+// app mount to it. Without one the mount is the live link, as before.
func (s *Server) handleSeed(w http.ResponseWriter, r *http.Request) {
- req, err := parseRequest(r, verbShape{tier: true})
+ req, err := parseRequest(r, verbShape{tier: true, exe: true, seed: true})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
unit := UnitName(RoleSeed, req.host)
- if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExe(req.host) }) {
+ if s.refuseStagedExe(w, req, unit) {
+ return
+ }
+ if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExeAt(req.host, req.exe) }) {
return
}
if err := s.setTier(ctx, req.host, req.tier); err != nil {
diff --git a/internal/instancectl/stagedexe.go b/internal/instancectl/stagedexe.go
index 39537c3e8..2d677dee8 100644
--- a/internal/instancectl/stagedexe.go
+++ b/internal/instancectl/stagedexe.go
@@ -84,8 +84,9 @@ func (s *Server) ValidateStagedExe(host string, x StagedExe) error {
// refuseStagedExe answers 403 for a start whose staged executable root
// cannot vouch for, and reports whether it did. A request that names none
-// is not refused here. It is the one place both start paths (a generation's
-// and the legacy sidecar's) validate, before anything is laid out.
+// is not refused here. It is the one place every path that binds a staged
+// build validates (a generation's start; the original run, seed and
+// sidecar units), before anything is laid out.
func (s *Server) refuseStagedExe(w http.ResponseWriter, req request, unit string) bool {
if req.exe.Path == "" {
return false
@@ -110,8 +111,9 @@ func (s *Server) ensureGenExeAt(host string, n uint64, x StagedExe) error {
// ensureHostExeAt lays out the host's own executable mount (the one the
// original templates bind: <ExeRoot>/<id>/app). It points at a validated
-// staged build when x names one, which only a legacy sidecar's start does,
-// and at the live link otherwise (ensureHostExe).
+// staged build when x names one (a legacy sidecar's start, or a run or seed
+// unit running the build its cold wake recorded), and at the live link
+// otherwise (ensureHostExe).
func (s *Server) ensureHostExeAt(host string, x StagedExe) error {
if x.Path == "" {
return s.ensureHostExe(host)
diff --git a/internal/instancectl/stagedexe_linux_test.go b/internal/instancectl/stagedexe_linux_test.go
index 80e0111ca..da863eb5f 100644
--- a/internal/instancectl/stagedexe_linux_test.go
+++ b/internal/instancectl/stagedexe_linux_test.go
@@ -158,14 +158,128 @@ func TestSidecarStartMountsTheValidatedStagedExe(t *testing.T) {
if !ctlCalled(ctl, "set-property --runtime "+SliceName(genHost)) {
t.Fatal("a flag-off sidecar start no longer sets the tier")
}
- // A run-role start on the original template names no executable.
+ // A run-role start on the original template may name an executable now
+ // (settings spec §4.2: the cold wake runs the build it recorded), and
+ // root still refuses one that is not the host's own validated build.
q := hostRole(genHost, RoleRun)
q.Set("memory_max", strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10))
q.Set("exe", staged)
q.Set("version", "v2")
- q.Set("digest", digest)
- if code, _, _ := c.do(context.Background(), DeadlineControl, http.MethodPost, "/start", q, nil); code != http.StatusBadRequest {
- t.Errorf("a run-role start with exe answered %d, want 400", code)
+ q.Set("digest", strings.Repeat("0", 64))
+ if code, _, _ := c.do(context.Background(), DeadlineControl, http.MethodPost, "/start", q, nil); code != http.StatusForbidden {
+ t.Errorf("a run-role start with a wrong digest answered %d, want 403", code)
+ }
+}
+
+// originalTemplateServer serves root's control channel over a version store
+// holding the host's own staged v2 and v3, with the live link on v2. It
+// returns the client, the systemctl fake, v2 as the exe root must accept,
+// the host's app mount and the opt dir.
+func originalTemplateServer(t *testing.T) (*Client, *fakeCtl, StagedExe, string, string) {
+ t.Helper()
+ opt, exeRoot := t.TempDir(), t.TempDir()
+ staged := release.VersionPath(opt, release.OpsAccount, "v2", "blog")
+ digest := writeStaged(t, staged, "build v2")
+ writeStaged(t, release.VersionPath(opt, release.OpsAccount, "v3", "blog"), "build v3")
+ repointLiveLink(t, opt, genHost, staged)
+ ctl := &fakeCtl{answers: map[string]string{
+ "show -p InvocationID,Result,ExecMainStatus,ExecMainCode": "InvocationID=feed\nResult=success\nExecMainStatus=0\nExecMainCode=exited\n",
+ }}
+ _, sock := serveOnSocket(t, ctl, func(s *Server) {
+ s.DropInDir, s.ExeRoot, s.OptDir = t.TempDir(), exeRoot, opt
+ s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil }
+ })
+ app := filepath.Join(exeRoot, provision.InstanceID(genHost), "app")
+ return NewClient(sock), ctl, StagedExe{Path: staged, Version: "v2", Digest: digest}, app, opt
+}
+
+// repointLiveLink is the agent's repoint of <opt>/live/<host>.
+func repointLiveLink(t *testing.T, opt, host, target string) {
+ t.Helper()
+ link := release.LiveLink(opt, host)
+ if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.Remove(link)
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Settings spec §4.2: the original run unit (generation 0) binds the
+// recorded staged file, validated under zdd-3's rule, and a repoint of the
+// live link after the start does not move it. A wrong digest is 403
+// (ErrStagedExe) before systemd or the mount is touched. A start that
+// names no executable binds the live link, as today.
+func TestTheRunUnitBindsAValidatedStagedExe(t *testing.T) {
+ c, ctl, x, app, opt := originalTemplateServer(t)
+ if _, err := c.StartExe(context.Background(), genHost, RoleRun, cgroup.TierDefault.MemoryMax, x); err != nil {
+ t.Fatalf("the host's own staged build was refused for its run unit: %v", err)
+ }
+ if !ctlCalled(ctl, "start "+UnitName(RoleRun, genHost)) {
+ t.Fatalf("the run unit was not started:\n%s", ctl.joined())
+ }
+ repointLiveLink(t, opt, genHost, release.VersionPath(opt, release.OpsAccount, "v3", "blog"))
+ if got, err := os.Readlink(app); err != nil || got != x.Path {
+ t.Fatalf("the run unit's app mount = %q (%v), want the recorded %q", got, err, x.Path)
+ }
+
+ ctl.reset()
+ bad := x
+ bad.Digest = strings.Repeat("0", 64)
+ if _, err := c.StartExe(context.Background(), genHost, RoleRun, cgroup.TierDefault.MemoryMax, bad); !errors.Is(err, ErrStagedExe) {
+ t.Fatalf("a wrong digest = %v, want ErrStagedExe", err)
+ }
+ if ctl.joined() != "" {
+ t.Fatalf("a refused start reached systemctl:\n%s", ctl.joined())
+ }
+ if got, _ := os.Readlink(app); got != x.Path {
+ t.Fatalf("a refused start moved the mount to %q", got)
+ }
+
+ if _, err := c.Start(context.Background(), genHost, RoleRun, cgroup.TierDefault.MemoryMax); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.Readlink(app); got != filepath.Join(LiveDir, genHost) {
+ t.Fatalf("a run start with no executable bound %q, want the live link", got)
+ }
+}
+
+// The seed unit binds the recorded staged file the same way, through
+// /seed, which names no role. A wrong digest is 403 before anything runs,
+// and a seed that names no executable binds the live link, as today.
+func TestTheSeedUnitBindsAValidatedStagedExe(t *testing.T) {
+ c, ctl, x, app, opt := originalTemplateServer(t)
+ res, err := c.SeedExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, x)
+ if err != nil || res.Result != "success" {
+ t.Fatalf("the host's own staged build was refused for its seed: %+v, %v", res, err)
+ }
+ if !ctlCalled(ctl, "start "+UnitName(RoleSeed, genHost)) {
+ t.Fatalf("the seed unit was not started:\n%s", ctl.joined())
+ }
+ repointLiveLink(t, opt, genHost, release.VersionPath(opt, release.OpsAccount, "v3", "blog"))
+ if got, err := os.Readlink(app); err != nil || got != x.Path {
+ t.Fatalf("the seed unit's app mount = %q (%v), want the recorded %q", got, err, x.Path)
+ }
+
+ ctl.reset()
+ bad := x
+ bad.Digest = strings.Repeat("0", 64)
+ if _, err := c.SeedExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, bad); !errors.Is(err, ErrStagedExe) {
+ t.Fatalf("a wrong digest = %v, want ErrStagedExe", err)
+ }
+ if ctl.joined() != "" {
+ t.Fatalf("a refused seed reached systemctl:\n%s", ctl.joined())
+ }
+ if got, _ := os.Readlink(app); got != x.Path {
+ t.Fatalf("a refused seed moved the mount to %q", got)
+ }
+
+ if _, err := c.Seed(context.Background(), genHost, cgroup.TierDefault.MemoryMax); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.Readlink(app); got != filepath.Join(LiveDir, genHost) {
+ t.Fatalf("a seed with no executable bound %q, want the live link", got)
}
}
@@ -226,7 +340,9 @@ func TestGenStartExeKeepsTheUnprovenAnswer(t *testing.T) {
}
}
-// exe, version and digest belong to the start verbs only.
+// exe, version and digest belong to the start and seed verbs only. On
+// those, an executable root cannot vouch for is refused (403) before
+// systemd is asked anything.
func TestStagedExeParametersRefusedWhereTheyDoNotBelong(t *testing.T) {
ctl := genCtl()
_, c := newServer(t, ctl, func(s *Server) { s.DropInDir = t.TempDir() })
@@ -234,14 +350,23 @@ func TestStagedExeParametersRefusedWhereTheyDoNotBelong(t *testing.T) {
"/stop?host=" + genHost + "&role=run&gen=7&exe=/x",
"/stop?host=" + genHost + "&role=sidecar&exe=/x",
"/reset?host=" + genHost + "&role=sidecar&version=v2",
- "/seed?host=" + genHost + "&memory_max=536870912&exe=/x",
- "/start?host=" + genHost + "&role=run&memory_max=536870912&exe=/x&version=v2&digest=d",
+ "/seed?host=" + genHost + "&memory_max=536870912&version=v2",
"/start?host=" + genHost + "&role=run&memory_max=536870912&gen=7&version=v2",
} {
if resp := post(t, c, path); resp.StatusCode != 400 {
t.Errorf("%s: status %d, want 400", path, resp.StatusCode)
}
}
+ // Settings spec §4.2: the original run unit and the seed unit may name
+ // an executable; this one is not the host's own validated build.
+ for _, path := range []string{
+ "/seed?host=" + genHost + "&memory_max=536870912&exe=/x",
+ "/start?host=" + genHost + "&role=run&memory_max=536870912&exe=/x&version=v2&digest=d",
+ } {
+ if resp := post(t, c, path); resp.StatusCode != http.StatusForbidden {
+ t.Errorf("%s: status %d, want 403", path, resp.StatusCode)
+ }
+ }
if ctl.joined() != "" {
t.Errorf("systemctl was called:\n%s", ctl.joined())
}