carlos / platform Public

activator/host: runner mints the token, runs without a store, redacts paths

I1: activator.EnsureAdminToken (exported, serialized on a process-wide
mutex) is now the runner's Token too — ensure-then-read in
host.scheduleToken. An instance asleep since before the roll has no
CARLOS_ADMIN_TOKEN and the tick IS the wake that would mint it; a read-only
Token recorded "no instance token" five times and never woke it. Spec §6.4
says "first spawn, seed, or first tick — whichever comes first".

I6: the runner starts whenever the activator is armed, store or no store —
once-timers land in the registry off the control socket, and a box that
mounts the verb must fire what it accepts. No store = no converger and no
report (scheduleDeps.conv nil; sd.converge is the nil-safe call the three
legs use), one log line saying so.

I7: EnsureAdminToken chmods the file 0600 after the append — O_CREATE's
mode never applied to a pre-existing 0644 file.

Redaction: activator.RedactPaths is exported; the host's Token and Deliver
wrappers redact their errors (they become last_status, then the bucket
status object and `carlos schedule ls`). Context errors are left alone so
the runner's errors.Is on the deadline still matches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f4a638969abd702878010dded6767b4c46bd4802 parent c059284
8 files changed, +244 −75
  • docs/superpowers/specs/2026-08-23-scheduled-work-design.md +15 −7
  • internal/activator/admintoken.go +29 −8
  • internal/activator/admintoken_test.go +18 −3
  • internal/activator/backend_exec.go +3 −3
  • internal/activator/logsink.go +6 −3
  • internal/host/agent.go +33 −24
  • internal/host/schedule_wiring.go +87 −27
  • internal/host/schedule_wiring_test.go +53 −0
diff --git a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
index dc9dcf132..cf1622e1e 100644
--- a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
+++ b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
@@ -336,13 +336,21 @@ runner sees it (box was down) fires **once** — §6.1's coalescing.
### 6.4 The instance secret: `CARLOS_ADMIN_TOKEN` becomes agent-minted
The tick's authenticity rests on the instance's own admin token, and today
-nothing mints it (§1). **(decision)** `ExecBackend.Start` (and `Seed`, which
-shares `instanceEnv`) ensures `<DataDir>/<host>.env` carries
-`CARLOS_ADMIN_TOKEN` before the child is spawned: absent, it mints 32 random
-bytes as hex and appends the line (file created `0600` if missing; an
-existing value is never touched or rotated). The overlay rule already makes
-this file win over bucket config (`mergeEnv`), so the token cannot be
-clobbered by `carlos env`.
+nothing mints it (§1). **(decision)** `activator.EnsureAdminToken` ensures
+`<DataDir>/<host>.env` carries `CARLOS_ADMIN_TOKEN`: absent, it mints 32
+random bytes as hex and appends the line (the file ends up `0600` whether it
+was created or already existed in a looser mode; an existing value is never
+touched or rotated). It is called by whichever comes first — the first
+spawn (`ExecBackend.Start`), the first seed (`Seed`, which shares
+`instanceEnv`), or the runner's first tick for the host (the runner's
+`Token` is ensure-then-read, `internal/host/schedule_wiring.go`). The third
+caller matters: an instance hibernating since before the roll has no token,
+and the tick is the wake that would have minted it — a read-only `Token`
+would record "no instance token" five times and never wake it. The three
+callers are serialized on one process-wide mutex, so a tick and a visitor
+racing on a fresh host mint one token, not two lines. The overlay rule
+already makes this file win over bucket config (`mergeEnv`), so the token
+cannot be clobbered by `carlos env`.
This also silently repairs the existing `/api/next-due` probe for every
console-created instance — a bug fix riding along, noted in the PR.
diff --git a/internal/activator/admintoken.go b/internal/activator/admintoken.go
index d1835492f..225de9f2b 100644
--- a/internal/activator/admintoken.go
+++ b/internal/activator/admintoken.go
@@ -13,8 +13,11 @@ package activator
// are the actual fix. Until this file nothing on the platform minted it: Eleven's own
// tooling wrote it by hand, console-created instances never had one at all,
// and the next-due probe failed silently for every one of them. Minted here,
-// once, on the first spawn (or seed) that finds it missing; never rotated by
-// the platform, because the running instance already holds the value and a
+// once, by whichever comes first: the first spawn, the first seed, or the
+// schedule runner's first tick for the host (internal/host/schedule_wiring.go
+// — an instance asleep since before the roll would otherwise wait for a
+// visitor to mint the token its own tick needs). Never rotated by the
+// platform, because the running instance already holds the value and a
// rotation behind its back would lock the agent out of its own instance.
import (
@@ -23,15 +26,26 @@ import (
"fmt"
"os"
"strings"
+ "sync"
)
-// ensureAdminToken makes sure path (an instance's own <host>.env) carries a
+// adminTokenMu serializes minting across the process. Two callers can find
+// the token missing at once — the runner's tick and a visitor-triggered
+// Start for the same host — and without this both would append a line,
+// leaving a file with two tokens of which readers see the last and the
+// first spawn's child holds the first.
+var adminTokenMu sync.Mutex
+
+// EnsureAdminToken makes sure path (an instance's own <host>.env) carries a
// CARLOS_ADMIN_TOKEN, minting one if it does not. The file is the instance's,
// not the agent's: every other line in it is preserved untouched and an
-// existing token is never rewritten. An absent file is created 0600 — the
-// same mode the env file carries everywhere else, and the only thing standing
-// between another UNIX user on the box and the instance's secret.
-func ensureAdminToken(path string) error {
+// existing token is never rewritten. The file ends up 0600 whether it was
+// created here or already existed in a looser mode — the same mode the env
+// file carries everywhere else, and the only thing standing between another
+// UNIX user on the box and the instance's secret.
+func EnsureAdminToken(path string) error {
+ adminTokenMu.Lock()
+ defer adminTokenMu.Unlock()
// Read the raw bytes once: they answer both questions this needs — is a
// token already there, and does the file end on a line boundary.
raw, err := os.ReadFile(path)
@@ -61,12 +75,19 @@ func ensureAdminToken(path string) error {
if _, err := fmt.Fprintf(f, "%sCARLOS_ADMIN_TOKEN=%s\n", prefix, hex.EncodeToString(secret)); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
+ // O_CREATE's mode applies only to a file created here. A pre-existing
+ // file written 0644 by hand (or by an older tool) now holds a secret, so
+ // it is tightened after the append, not before: chmod on a file the
+ // write then failed on would be a change for nothing.
+ if err := os.Chmod(path, 0o600); err != nil {
+ return fmt.Errorf("chmod %s: %w", path, err)
+ }
return nil
}
// parseEnvBytes parses an EnvironmentFile already in hand: KEY=VALUE lines,
// blank lines and # comments skipped. It is readEnvFile's whole body (that
-// one adds the read), split out so ensureAdminToken can answer "is the token
+// one adds the read), split out so EnsureAdminToken can answer "is the token
// set" and "does the file end in a newline" from ONE read of the file — two
// reads could disagree, and the second one decides where the token lands.
func parseEnvBytes(raw []byte) map[string]string {
diff --git a/internal/activator/admintoken_test.go b/internal/activator/admintoken_test.go
index 0a5c2a898..a2c095408 100644
--- a/internal/activator/admintoken_test.go
+++ b/internal/activator/admintoken_test.go
@@ -9,7 +9,7 @@ import (
func TestEnsureAdminTokenMintsOnce(t *testing.T) {
p := filepath.Join(t.TempDir(), "h.env")
- if err := ensureAdminToken(p); err != nil {
+ if err := EnsureAdminToken(p); err != nil {
t.Fatal(err)
}
st, _ := os.Stat(p)
@@ -21,7 +21,7 @@ func TestEnsureAdminTokenMintsOnce(t *testing.T) {
if len(tok) != 64 {
t.Fatalf("token = %q", tok)
}
- if err := ensureAdminToken(p); err != nil {
+ if err := EnsureAdminToken(p); err != nil {
t.Fatal(err)
}
env, _ = readEnvFile(p)
@@ -33,7 +33,7 @@ func TestEnsureAdminTokenMintsOnce(t *testing.T) {
func TestEnsureAdminTokenPreservesOtherVars(t *testing.T) {
p := filepath.Join(t.TempDir(), "h.env")
os.WriteFile(p, []byte("DATABASE_URL=sqlite\n# comment\n"), 0o600)
- if err := ensureAdminToken(p); err != nil {
+ if err := EnsureAdminToken(p); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(p)
@@ -45,3 +45,18 @@ func TestEnsureAdminTokenPreservesOtherVars(t *testing.T) {
t.Fatal("other var lost")
}
}
+
+// A looser-mode file that already exists is tightened by the mint: O_CREATE's
+// 0600 only ever applied to a file created here, and a 0644 file now holds a
+// secret every other UNIX user on the box could read.
+func TestEnsureAdminTokenTightensAnExistingFile(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "h.env")
+ os.WriteFile(p, []byte("DATABASE_URL=sqlite\n"), 0o644)
+ if err := EnsureAdminToken(p); err != nil {
+ t.Fatal(err)
+ }
+ st, _ := os.Stat(p)
+ if st.Mode().Perm() != 0o600 {
+ t.Fatalf("mode after mint = %o, want 600", st.Mode().Perm())
+ }
+}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 08cfcedf4..beb11f9f4 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -284,7 +284,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error {
// thing a fresh instance runs, so this is usually where the token comes
// into existence, and instanceEnv's contract is that a seed sees the same
// environment the instance will.
- if err := ensureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil {
+ if err := EnsureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil {
return fmt.Errorf("admin token for %s: %w", host, err)
}
fileEnv, err := b.instanceEnv(host)
@@ -513,7 +513,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
// instance with no token can neither answer /api/next-due nor accept a
// tick, and a wake that quietly skipped it would report a healthy instance
// that silently never runs its scheduled work — the #110 kind of lie.
- if err := ensureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil {
+ if err := EnsureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil {
return nil, "", fmt.Errorf("admin token for %s: %w", host, err)
}
@@ -657,7 +657,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
// line with no "stopping" line before it — that pairing, not the
// exit code alone, is what tells a member crash from lifecycle.
if p.err != nil {
- emitEvent(b.Logs, rt, "warn", "instance exited: "+redactPaths(p.err.Error()))
+ emitEvent(b.Logs, rt, "warn", "instance exited: "+RedactPaths(p.err.Error()))
} else {
emitEvent(b.Logs, rt, "info", "instance exited (exit 0)")
}
diff --git a/internal/activator/logsink.go b/internal/activator/logsink.go
index e8fa84242..3e3eb4508 100644
--- a/internal/activator/logsink.go
+++ b/internal/activator/logsink.go
@@ -56,11 +56,14 @@ func memberSafeWakeReason(err error) string {
case strings.Contains(s, "no adoptable head"):
return "wake failed: nothing is promoted for this instance's channel (or the head is still bake-held) — promote a build, or check `carlos channels`"
}
- return "wake failed: " + redactPaths(s)
+ return "wake failed: " + RedactPaths(s)
}
-// redactPaths reduces absolute-path tokens to their base names.
-func redactPaths(s string) string {
+// RedactPaths reduces absolute-path tokens to their base names. Exported
+// for the host package's schedule wiring, whose token and delivery errors
+// reach the bucket status object and `carlos schedule ls` (#131's rule:
+// no box filesystem layout in a member-facing string).
+func RedactPaths(s string) string {
return absPathToken.ReplaceAllStringFunc(s, func(m string) string {
i := strings.Index(m, "/")
prefix, path := m[:i], m[i:]
diff --git a/internal/host/agent.go b/internal/host/agent.go
index a39f1cd88..23b14482e 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -322,23 +322,36 @@ func RunAgent(args []string) {
// a console bump fires the fast one); Converger.Pass serializes itself on
// its own mutex, so no call site here needs one.
//
- // A box with no deploy store simply has no scheduled work: schedules live
- // in the bucket, so there is nothing to converge and nowhere to report.
- // One line, like every other optional loop.
+ // The two halves arm on different conditions. Declared schedules live in
+ // the bucket, so the converger needs the deploy store. The RUNNER needs
+ // only the registry: once-timers are registered by instances on their
+ // control socket (mounted above, Timers: reg, whenever the activator is
+ // armed) straight into this box's rows, and a box that mounts the verb
+ // must also fire what it accepts — so the runner starts whenever the
+ // activator is armed, store or no store. One line each way, like every
+ // other optional loop.
+ //
+ // Guarded assignment, not a direct pass: logSink is a *applog.Sink, and
+ // a nil one handed to an interface parameter is NOT a nil interface —
+ // the same trap the activator's Logs assignment above avoids.
+ var sink activator.EventSink
+ if logSink != nil {
+ sink = logSink
+ }
var sd *scheduleDeps
- if schedStore, err := openDeployStore(context.Background()); err != nil {
- log.Printf("agent: scheduled work disabled: %v", err)
- } else {
- // Guarded assignment, not a direct pass: logSink is a *applog.Sink, and
- // a nil one handed to an interface parameter is NOT a nil interface —
- // the same trap the activator's Logs assignment above avoids.
- var sink activator.EventSink
- if logSink != nil {
- sink = logSink
- }
+ schedStore, schedErr := openDeployStore(context.Background())
+ switch {
+ case schedErr == nil:
sd = newScheduleDeps(reg, schedStore, svc, sink, dd)
- sd.runner.Start(context.Background()) // runs until the process ends, like every loop below
log.Printf("agent: scheduled work armed (converge on serial + hourly; runner every 15s)")
+ case svc != nil:
+ sd = newScheduleDeps(reg, nil, svc, sink, dd)
+ log.Printf("agent: schedules from the bucket are off (%v); once-timers registered by instances still run (runner every 15s)", schedErr)
+ default:
+ log.Printf("agent: scheduled work disabled: %v", schedErr)
+ }
+ if sd != nil {
+ sd.runner.Start(context.Background()) // runs until the process ends, like every loop below
}
go adoptLoop(reg, svc, sd)
@@ -497,8 +510,8 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st
// hibernation armed simply has no children to bounce.
//
// sd carries the scheduled-work converger; this loop is its HOURLY floor (the
-// fast one rides the serial in fastConvergeLoop). Nil when the box has no
-// deploy store — the agent logged that once at arming.
+// fast one rides the serial in fastConvergeLoop). sd.converge is a no-op when
+// the box has no deploy store — the agent logged that once at arming.
func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps) {
ctx := context.Background()
store, err := openDeployStore(ctx)
@@ -557,10 +570,8 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps)
// release pass: an app whose schedules.json arrived with a new version
// gets its rows against the version that is actually staged. Die-free
// per app inside Pass — the joined error is one log line, never fatal.
- if sd != nil {
- if err := sd.conv.Pass(ctx); err != nil {
- log.Printf("agent: schedule converge (hourly): %v", err)
- }
+ if err := sd.converge(ctx); err != nil {
+ log.Printf("agent: schedule converge (hourly): %v", err)
}
}
}
@@ -1288,10 +1299,8 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
// up to an hour of a schedule silently not running. It also seeds the
// converger's etag memory, which is what makes a run signal older than this
// process read as history rather than as a fresh request to run.
- if sd != nil {
- if err := sd.conv.Pass(ctx); err != nil {
- log.Printf("agent: schedule converge (start): %v", err)
- }
+ if err := sd.converge(ctx); err != nil {
+ log.Printf("agent: schedule converge (start): %v", err)
}
d := &fastDeps{
reg: reg, store: store, pub: pub, optDir: optDir,
diff --git a/internal/host/schedule_wiring.go b/internal/host/schedule_wiring.go
index 683f3484b..d6c26a17e 100644
--- a/internal/host/schedule_wiring.go
+++ b/internal/host/schedule_wiring.go
@@ -7,6 +7,7 @@ import (
"log"
"net/http"
"os"
+ "path/filepath"
"strings"
"time"
@@ -36,65 +37,117 @@ import (
// etag memory is what keeps an idle box at one GET per app per pass and makes
// a run signal exactly-once, so a second instance in another loop would
// re-seed that memory and either replay a run or swallow one.
+//
+// The two halves have different preconditions. The converger needs the deploy
+// store (schedules live in the bucket). The runner does not: once-timers are
+// registered by instances on their own control socket (spec §7.2) straight
+// into this box's registry, so a box with an armed activator and no store
+// still has rows to fire — conv is nil there, and nothing is reported.
type scheduleDeps struct {
- conv *schedule.Converger
+ conv *schedule.Converger // nil when the box has no deploy store
runner *schedule.Runner
}
// newScheduleDeps assembles the converger and the runner around this agent's
// registry, deploy store and activator.
//
-// svc may be nil (no hibernation armed on this box): delivery then proxies
-// straight to the instance's socket, which is exactly right — an instance that
-// is not activator-managed is either already running or not this leg's problem.
-// sink may be nil (no log store): the runner simply emits no member events.
-// Callers must pass a genuinely nil interface for sink, not a nil *applog.Sink
-// — see the trap agent.go documents where logSink is threaded.
+// store may be nil (no deploy store on this box): no converger is built and
+// the runner reports nothing, but it still fires the once rows instances
+// register. svc may be nil (no hibernation armed on this box): delivery then
+// proxies straight to the instance's socket, which is exactly right — an
+// instance that is not activator-managed is either already running or not
+// this leg's problem. sink may be nil (no log store): the runner simply emits
+// no member events. Callers must pass a genuinely nil interface for sink, not
+// a nil *applog.Sink — see the trap agent.go documents where logSink is
+// threaded.
func newScheduleDeps(reg *registry.Registry, store release.Store, svc *activator.Service, sink activator.EventSink, dd string) *scheduleDeps {
box, err := os.Hostname()
if err != nil || box == "" {
box = "unknown"
}
- // The reporter is shared by both halves on purpose: a converge that adds a
- // row and a tick that fires it write the SAME status object, so whichever
- // ran last is what a member reads. Its own timeout, not the caller's — a
- // slow bucket must not hold a converge pass or a tick goroutine open.
- report := func(rt registry.Route, st schedule.Status) {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if err := schedule.WriteStatus(ctx, store, release.RouteAccount(rt), rt.App, st); err != nil {
- log.Printf("schedule: report %s: %v", rt.Host, err)
- }
- }
var event func(rt registry.Route, level, msg string)
if sink != nil {
event = func(rt registry.Route, level, msg string) {
sink.Event(release.RouteAccount(rt), rt.App, rt.Host, level, msg)
}
}
- return &scheduleDeps{
- conv: schedule.NewConverger(reg, store, box, report),
+ sd := &scheduleDeps{
runner: &schedule.Runner{
Reg: reg,
Deliver: activatorDeliverer{svc: svc},
- Token: schedule.TokenFromEnvFile(dd), // the activator's own <host>.env
+ Token: scheduleToken(dd),
Event: event,
- Report: report,
Box: box,
Build: version.Build,
},
}
+ if store == nil {
+ return sd
+ }
+ // The reporter is shared by both halves on purpose: a converge that adds a
+ // row and a tick that fires it write the SAME status object, so whichever
+ // ran last is what a member reads. Its own timeout, not the caller's — a
+ // slow bucket must not hold a converge pass or a tick goroutine open.
+ report := func(rt registry.Route, st schedule.Status) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := schedule.WriteStatus(ctx, store, release.RouteAccount(rt), rt.App, st); err != nil {
+ log.Printf("schedule: report %s: %v", rt.Host, err)
+ }
+ }
+ sd.conv = schedule.NewConverger(reg, store, box, report)
+ sd.runner.Report = report
+ return sd
+}
+
+// scheduleToken is the runner's Token: ensure-then-read on the activator's
+// own <dataDir>/<host>.env (spec §6.4).
+//
+// Ensure, not just read, because the token is otherwise minted only when the
+// activator spawns the instance — and the tick is what would spawn it. An
+// instance asleep since before this build rolled has no token; a read-only
+// Token would record "no instance token" five times and give up, and the
+// wake that mints it would never come. EnsureAdminToken is the same call
+// Start and Seed make, serialized with them, so a tick and a visitor racing
+// on a fresh host mint ONE token.
+//
+// The error is redacted: it reaches the row's last_status and from there the
+// bucket status object and `carlos schedule ls`, and a member must not learn
+// the box's filesystem layout from it.
+func scheduleToken(dd string) func(host string) (string, error) {
+ read := schedule.TokenFromEnvFile(dd)
+ return func(host string) (string, error) {
+ if err := activator.EnsureAdminToken(filepath.Join(dd, host+".env")); err != nil {
+ return "", errors.New(activator.RedactPaths(err.Error()))
+ }
+ tok, err := read(host)
+ if err != nil {
+ return "", errors.New(activator.RedactPaths(err.Error()))
+ }
+ return tok, nil
+ }
+}
+
+// converge runs one converger pass, or nothing on a box with no deploy
+// store. The agent's three call sites go through this so none of them can
+// reach into a nil conv.
+func (sd *scheduleDeps) converge(ctx context.Context) error {
+ if sd == nil || sd.conv == nil {
+ return nil
+ }
+ return sd.conv.Pass(ctx)
}
// scheduleLegFor is fastDeps.scheduleLeg for sd — a genuinely nil func when
-// scheduled work is not armed, so the tick skips the leg rather than calling a
-// closure over a nil converger.
+// there is nothing to converge (no scheduled work armed, or no deploy store
+// to read declarations from), so the tick skips the leg rather than calling
+// a closure over a nil converger.
func scheduleLegFor(sd *scheduleDeps) func(ctx context.Context) {
- if sd == nil {
+ if sd == nil || sd.conv == nil {
return nil
}
return func(ctx context.Context) {
- if err := sd.conv.Pass(ctx); err != nil {
+ if err := sd.converge(ctx); err != nil {
log.Printf("agent: schedule converge: %v", err)
}
}
@@ -129,7 +182,14 @@ func (d activatorDeliverer) Deliver(ctx context.Context, rt registry.Route, req
proxy.ServeHTTP(w, r)
}
})
- return deliverVia(ctx, h, rt, req)
+ status, err := deliverVia(ctx, h, rt, req)
+ if err != nil && ctx.Err() == nil {
+ // Redacted for the same reason as the token error: this string is
+ // the row's last_status. The context's own error is left alone —
+ // the runner matches it with errors.Is to tell a timeout apart.
+ err = errors.New(activator.RedactPaths(err.Error()))
+ }
+ return status, err
}
// deliverVia is the pure core: run the handler against a recorder and turn
diff --git a/internal/host/schedule_wiring_test.go b/internal/host/schedule_wiring_test.go
index b25138a3e..2cd397fc8 100644
--- a/internal/host/schedule_wiring_test.go
+++ b/internal/host/schedule_wiring_test.go
@@ -4,9 +4,12 @@ import (
"context"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/registry"
)
@@ -105,3 +108,53 @@ func TestStatusRecorderHeadersAndFirstLine(t *testing.T) {
t.Fatalf("Retry-After = %q", got)
}
}
+
+// The runner's Token MINTS: an instance asleep since before the roll has no
+// CARLOS_ADMIN_TOKEN, and the wake that would mint one is the tick itself. So
+// a route with no env file gets a token from the runner's Token func, and the
+// spawn that follows (EnsureAdminToken, as Start and Seed call it) finds the
+// same one rather than appending a second.
+func TestScheduleTokenMintsForASleepingInstance(t *testing.T) {
+ dd := t.TempDir()
+ const host = "asleep.bab.oncarlos.com"
+ envPath := filepath.Join(dd, host+".env")
+ if _, err := os.Stat(envPath); !os.IsNotExist(err) {
+ t.Fatalf("fixture: env file already exists (%v)", err)
+ }
+ tok, err := scheduleToken(dd)(host)
+ if err != nil {
+ t.Fatalf("Token on a host with no env file: %v", err)
+ }
+ if len(tok) != 64 {
+ t.Fatalf("minted token = %q, want 32 random bytes as hex", tok)
+ }
+ if st, err := os.Stat(envPath); err != nil || st.Mode().Perm() != 0o600 {
+ t.Fatalf("env file after mint: %v mode %v, want 0600", err, st.Mode().Perm())
+ }
+ // The spawn's own ensure is a no-op now, and reads back the same token.
+ if err := activator.EnsureAdminToken(envPath); err != nil {
+ t.Fatal(err)
+ }
+ again, err := scheduleToken(dd)(host)
+ if err != nil || again != tok {
+ t.Fatalf("second read = %q (%v), want the first token %q", again, err, tok)
+ }
+ raw, _ := os.ReadFile(envPath)
+ if strings.Count(string(raw), "CARLOS_ADMIN_TOKEN=") != 1 {
+ t.Fatalf("env file carries %d tokens, want 1:\n%s", strings.Count(string(raw), "CARLOS_ADMIN_TOKEN="), raw)
+ }
+}
+
+// A Token error becomes the row's last_status and from there the bucket
+// status object and `carlos schedule ls`: it must not carry the box's
+// filesystem layout.
+func TestScheduleTokenErrorCarriesNoAbsolutePath(t *testing.T) {
+ dd := filepath.Join(t.TempDir(), "missing-data-dir")
+ _, err := scheduleToken(dd)("x.bab.oncarlos.com")
+ if err == nil {
+ t.Fatal("Token succeeded with no data dir")
+ }
+ if strings.Contains(err.Error(), dd) || strings.Contains(err.Error(), "/") {
+ t.Fatalf("error leaks a path: %q", err)
+ }
+}