package activator import ( "context" "errors" "fmt" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/instancectl" "strings" "testing" "time" ) // TestMemberSafeWakeReason pins the two real 2026-08-09 cases from #131 โ€” // each needs a different response from the member, and neither may carry // box filesystem layout. func TestMemberSafeWakeReason(t *testing.T) { cases := []struct { name string err error want []string // substrings that must appear deny []string // substrings that must NOT appear }{ { "stale socket (#129)", errors.New(`start ledgerpay.bde.oncarlos.com: listen unix /data/exec/ledgerpay.bde.oncarlos.com/inst.sock: bind: address already in use`), []string{"socket already in use", "previous run"}, []string{"/data", "bind:"}, }, { "nothing promoted (#130)", errors.New(`materialize ledgerpay.bde.oncarlos.com: no adoptable head (nothing promoted for its channel, or the head is bake-held)`), []string{"nothing is promoted", "carlos channels"}, nil, }, { // skills#29: a static head under an instance route. This one is // ordered BEFORE the "no adoptable head" case in the renderer, // and the ordering is the whole point โ€” materialize's sentence // contains both markers, and matching the generic one first // would throw away the only diagnosis that names the real // problem and print the reassuring falsehood instead. "a static release under an instance route", errors.New(`materialize techarena.bak.platform.tito.io: the head is a static release: techarena 0.1.0 ` + `is promoted on edge, but a static site is served straight off the channel pointer and has no ` + `binary to run, so this route can never wake into it. A static site needs a static route: ` + "`carlos instances delete --app techarena --host techarena.bak.platform.tito.io`, then " + "`carlos deploy --app techarena --kind static --host techarena.bak.platform.tito.io `"), []string{"static release", "--kind static", "0.1.0", "carlos deploy"}, []string{"nothing is promoted"}, // the contradiction that cost a real session its afternoon }, { "unknown errors keep meaning, lose layout", errors.New(`seed h: fork/exec /opt/carlos/versions/ledgerpay/1.2.3/ledgerpay: permission denied`), []string{"wake failed", "permission denied", "ledgerpay"}, []string{"/opt/carlos"}, }, { // A wake refused while the last failed wake's generation is // unproven (zero-downtime spec ยง7.4) reads as the fence's // existing text: the platform holds the host closed until its // own work is done. No new member-facing text. "a wake refused over an unproven generation", fmt.Errorf("h.example.com: refusing to wake: %w", errWakeUnproven), []string{memberSafeWakeReason(ErrFenced)}, []string{"generation", "proven"}, }, { // The first failure itself (final review F5): a contained start // that failed its health check and whose generation kill was not // proven. Its sentinel's text is operator detail; the member gets // the same fence text. With generations off too (containment can // be on without them). "a start whose generation is unproven", fmt.Errorf("start h.example.com: health: no answer: %w", fmt.Errorf("kill g7: %w", errGenUnproven)), []string{memberSafeWakeReason(ErrFenced)}, []string{"generation", "proven", "health"}, }, { "a nested start whose generation is unproven", fmt.Errorf("start h.example.com: %w", instancectl.ErrGenUnproven), []string{memberSafeWakeReason(ErrFenced)}, []string{"generation", "unproven", "instance control"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := memberSafeWakeReason(tc.err) for _, w := range tc.want { if !strings.Contains(got, w) { t.Errorf("missing %q in %q", w, got) } } for _, d := range tc.deny { if strings.Contains(got, d) { t.Errorf("leaked %q in %q", d, got) } } }) } } // eventRec and fakeSink are the package's EventSink fixture. type eventRec struct{ acct, app, host, level, msg string } type fakeSink struct { apps []eventRec events []eventRec } func (f *fakeSink) App(acct, app, host, line string) { f.apps = append(f.apps, eventRec{acct, app, host, "", line}) } func (f *fakeSink) Event(acct, app, host, level, msg string) { f.events = append(f.events, eventRec{acct, app, host, level, msg}) } // TestWakeOutcomesReachTheSink: the activator's wake path publishes exactly // one member-facing event per outcome โ€” the #131 fact, at its source. The // route carries app+account context so the sink can key the record. func TestWakeOutcomesReachTheSink(t *testing.T) { t.Parallel() // Failure: Start refuses, the event is the member-safe rendering. a, b, _, _ := testActivator(t, time.Hour) sink := &fakeSink{} a.Logs = sink b.startErr = errors.New("listen unix /data/exec/h/inst.sock: bind: address already in use") rt := testRoute("ledgerpay.bde.oncarlos.com") rt.App = "ledgerpay" rt.Slug = "" if _, err := a.beginRequest(context.Background(), rt); err == nil { t.Fatal("wake should have failed") } var wakeFails []eventRec for _, e := range sink.events { if strings.Contains(e.msg, "wake failed") { wakeFails = append(wakeFails, e) } } if len(wakeFails) != 1 { t.Fatalf("wake-failure events: %d (%v)", len(wakeFails), sink.events) } ev := wakeFails[0] if ev.level != "error" || ev.host != "ledgerpay.bde.oncarlos.com" || ev.app != "ledgerpay" { t.Fatalf("event mis-tagged: %+v", ev) } if strings.Contains(ev.msg, "/data") || !strings.Contains(ev.msg, "socket already in use") { t.Fatalf("event not member-safe: %q", ev.msg) } // Success: a woke event, tagged the same way. a2, _, _, _ := testActivator(t, time.Hour) sink2 := &fakeSink{} a2.Logs = sink2 rel, err := a2.beginRequest(context.Background(), rt) if err != nil { t.Fatalf("wake failed: %v", err) } rel() var woke bool for _, e := range sink2.events { if strings.Contains(e.msg, "instance woke") && e.level == "info" { woke = true } } if !woke { t.Fatalf("no woke event: %+v", sink2.events) } } // A claims-on wake on the closed launch fails before its exec hook, and // startAdmitted wraps that in errClaimsAdmission so the one classifier // reads it as the admission's (Task 10). The wrapper is internal: the // member's event renders exactly as the bare cause did on main, whatever // the flag, alone or joined with FailedAdmission's error as doClaimWake // returns it. No "claims admission" text reaches a member. func TestMemberSafeWakeReasonHidesTheClaimsAdmissionWrapper(t *testing.T) { recovery := errors.New("recovery: release the admission") for _, tc := range []struct { name string wrapped, bare error }{ {"the closed launch", claimAdmission(ErrClaimIsolationUnavailable, false), ErrClaimIsolationUnavailable}, {"the closed launch, wrapped by the launcher", claimAdmission(fmt.Errorf("start main: %w", ErrClaimIsolationUnavailable), false), fmt.Errorf("start main: %w", ErrClaimIsolationUnavailable)}, {"joined as doClaimWake returns it", errors.Join(claimAdmission(ErrClaimIsolationUnavailable, false), nil), errors.Join(ErrClaimIsolationUnavailable, nil)}, {"joined with a recovery error", errors.Join(claimAdmission(ErrClaimIsolationUnavailable, false), recovery), errors.Join(ErrClaimIsolationUnavailable, recovery)}, } { got, want := memberSafeWakeReason(tc.wrapped), memberSafeWakeReason(tc.bare) if got != want { t.Errorf("%s: %q, want main's %q", tc.name, got, want) } if strings.Contains(got, "claims admission") { t.Errorf("%s: the admission wrapper reached a member: %q", tc.name, got) } } } // Amadan #272: a wake refused because the host's parent cgroup holds a // process directly reads to a member as the maintenance fence's approved // text, not as cgroup detail. func TestMemberSafeWakeReasonHidesALegacyParentRefusal(t *testing.T) { err := fmt.Errorf("h.example.com: %w: h.example.com holds 2 process(es)", cgroup.ErrLegacyParentPopulated) got := memberSafeWakeReason(err) if got != memberSafeWakeReason(ErrFenced) || strings.Contains(got, "cgroup") || strings.Contains(got, "process") { t.Errorf("member reason = %q, want the fence text alone", got) } } // Amadan #272, Task 19: a wake refused because its cgroup took cgroup.kill // and could not be made fresh reads to a member as the maintenance fence's // approved text, not as cgroup detail. Nothing of the build ran, so the // wake is never one that may have started (startMayHaveRun). func TestMemberSafeWakeReasonHidesAKilledCgroupRefusal(t *testing.T) { err := fmt.Errorf("%w: /sys/fs/cgroup/x/carlos-exec@h.example.com: device or resource busy", cgroup.ErrKilledCgroupKept) got := memberSafeWakeReason(err) if got != memberSafeWakeReason(ErrFenced) || strings.Contains(got, "cgroup") || strings.Contains(got, "busy") { t.Errorf("member reason = %q, want the fence text alone", got) } if startMayHaveRun(nil, err) { t.Error("a start refused for a killed cgroup counted as one that may have run") } } // Task 7 (readiness round 2): a wake refused because its generation's limits // could not be applied reads to a member as the maintenance fence's approved // text, not as cgroup detail. Nothing of the build ran. func TestMemberSafeWakeReasonHidesALimitNotAppliedRefusal(t *testing.T) { for _, err := range []error{ fmt.Errorf("h.example.com: %w: g2: memory.max: open /sys/fs/cgroup/x/carlos-exec@h.example.com/g2/memory.max: permission denied", cgroup.ErrLimitNotApplied), fmt.Errorf("%w: h.example.com: the placement shim is not available", cgroup.ErrLimitNotApplied), } { got := memberSafeWakeReason(err) if got != memberSafeWakeReason(ErrFenced) || strings.Contains(got, "cgroup") || strings.Contains(got, "memory") { t.Errorf("member reason = %q, want the fence text alone", got) } if startMayHaveRun(nil, err) { t.Error("a start refused for its limits counted as one that may have run") } } } // Whole-branch review round 1 of zdd readiness 3, finding 3. A start whose // exec.CommandContext context ends while the cgroup manager's shim is being // placed answers the context's own error, bare, as exec.Cmd.Start answers // an ended context (cgroup's startIn; its unit and kernel tests pin that // shape). The only spawn built with a context is the seed, so this is that // error as the seed wraps it and the wake event renders it: the classifier // reads a cancellation as the caller's own end, and the member reads what a // direct start's ended context always read, with no placement, shim or // cgroup words (those stay in the agent's log line) and no new text. func TestCancelledPlacementReadsAsTheCallersOwnEnd(t *testing.T) { const host = "h.example.com" cancelled := fmt.Errorf("seeding %s: %w", host, context.Canceled) timedOut := fmt.Errorf("seeding %s: %w", host, fmt.Errorf("timed out after %s and was killed: %w", seedTimeout, context.DeadlineExceeded)) if got := classifyStep(nil, cancelled); got != stepCancelled { t.Errorf("classifyStep(cancelled placement) = %v, want stepCancelled", got) } if got, want := classifyStep(nil, timedOut), classifyStep(nil, context.DeadlineExceeded); got != want { t.Errorf("classifyStep(timed-out placement) = %v, want %v, as for a direct start's deadline", got, want) } for err, want := range map[error]string{ cancelled: "wake failed: seeding h.example.com: context canceled", timedOut: "wake failed: seeding h.example.com: timed out after " + seedTimeout.String() + " and was killed: context deadline exceeded", } { got := memberSafeWakeReason(err) if got != want { t.Errorf("member reason = %q, want %q", got, want) } for _, word := range []string{"placement", "shim", "cgroup"} { if strings.Contains(got, word) { t.Errorf("member reason %q carries %q", got, word) } } if startMayHaveRun(nil, err) { t.Errorf("a start whose context ended during placement counted as one that may have run: %v", err) } } }