carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "github.com/carlosframework/platform/internal/cgroup"
8 "github.com/carlosframework/platform/internal/instancectl"
9 "strings"
10 "testing"
11 "time"
12)
13
14// TestMemberSafeWakeReason pins the two real 2026-08-09 cases from #131 —
15// each needs a different response from the member, and neither may carry
16// box filesystem layout.
17func TestMemberSafeWakeReason(t *testing.T) {
18 cases := []struct {
19 name string
20 err error
21 want []string // substrings that must appear
22 deny []string // substrings that must NOT appear
23 }{
24 {
25 "stale socket (#129)",
26 errors.New(`start ledgerpay.bde.oncarlos.com: listen unix /data/exec/ledgerpay.bde.oncarlos.com/inst.sock: bind: address already in use`),
27 []string{"socket already in use", "previous run"},
28 []string{"/data", "bind:"},
29 },
30 {
31 "nothing promoted (#130)",
32 errors.New(`materialize ledgerpay.bde.oncarlos.com: no adoptable head (nothing promoted for its channel, or the head is bake-held)`),
33 []string{"nothing is promoted", "carlos channels"},
34 nil,
35 },
36 {
37 // skills#29: a static head under an instance route. This one is
38 // ordered BEFORE the "no adoptable head" case in the renderer,
39 // and the ordering is the whole point — materialize's sentence
40 // contains both markers, and matching the generic one first
41 // would throw away the only diagnosis that names the real
42 // problem and print the reassuring falsehood instead.
43 "a static release under an instance route",
44 errors.New(`materialize techarena.bak.platform.tito.io: the head is a static release: techarena 0.1.0 ` +
45 `is promoted on edge, but a static site is served straight off the channel pointer and has no ` +
46 `binary to run, so this route can never wake into it. A static site needs a static route: ` +
47 "`carlos instances delete --app techarena --host techarena.bak.platform.tito.io`, then " +
48 "`carlos deploy --app techarena --kind static --host techarena.bak.platform.tito.io <site-dir>`"),
49 []string{"static release", "--kind static", "0.1.0", "carlos deploy"},
50 []string{"nothing is promoted"}, // the contradiction that cost a real session its afternoon
51 },
52 {
53 "unknown errors keep meaning, lose layout",
54 errors.New(`seed h: fork/exec /opt/carlos/versions/ledgerpay/1.2.3/ledgerpay: permission denied`),
55 []string{"wake failed", "permission denied", "ledgerpay"},
56 []string{"/opt/carlos"},
57 },
58 {
59 // A wake refused while the last failed wake's generation is
60 // unproven (zero-downtime spec §7.4) reads as the fence's
61 // existing text: the platform holds the host closed until its
62 // own work is done. No new member-facing text.
63 "a wake refused over an unproven generation",
64 fmt.Errorf("h.example.com: refusing to wake: %w", errWakeUnproven),
65 []string{memberSafeWakeReason(ErrFenced)},
66 []string{"generation", "proven"},
67 },
68 {
69 // The first failure itself (final review F5): a contained start
70 // that failed its health check and whose generation kill was not
71 // proven. Its sentinel's text is operator detail; the member gets
72 // the same fence text. With generations off too (containment can
73 // be on without them).
74 "a start whose generation is unproven",
75 fmt.Errorf("start h.example.com: health: no answer: %w", fmt.Errorf("kill g7: %w", errGenUnproven)),
76 []string{memberSafeWakeReason(ErrFenced)},
77 []string{"generation", "proven", "health"},
78 },
79 {
80 "a nested start whose generation is unproven",
81 fmt.Errorf("start h.example.com: %w", instancectl.ErrGenUnproven),
82 []string{memberSafeWakeReason(ErrFenced)},
83 []string{"generation", "unproven", "instance control"},
84 },
85 }
86 for _, tc := range cases {
87 t.Run(tc.name, func(t *testing.T) {
88 got := memberSafeWakeReason(tc.err)
89 for _, w := range tc.want {
90 if !strings.Contains(got, w) {
91 t.Errorf("missing %q in %q", w, got)
92 }
93 }
94 for _, d := range tc.deny {
95 if strings.Contains(got, d) {
96 t.Errorf("leaked %q in %q", d, got)
97 }
98 }
99 })
100 }
101}
102
103// eventRec and fakeSink are the package's EventSink fixture.
104type eventRec struct{ acct, app, host, level, msg string }
105
106type fakeSink struct {
107 apps []eventRec
108 events []eventRec
109}
110
111func (f *fakeSink) App(acct, app, host, line string) {
112 f.apps = append(f.apps, eventRec{acct, app, host, "", line})
113}
114func (f *fakeSink) Event(acct, app, host, level, msg string) {
115 f.events = append(f.events, eventRec{acct, app, host, level, msg})
116}
117
118// TestWakeOutcomesReachTheSink: the activator's wake path publishes exactly
119// one member-facing event per outcome — the #131 fact, at its source. The
120// route carries app+account context so the sink can key the record.
121func TestWakeOutcomesReachTheSink(t *testing.T) {
122 t.Parallel()
123 // Failure: Start refuses, the event is the member-safe rendering.
124 a, b, _, _ := testActivator(t, time.Hour)
125 sink := &fakeSink{}
126 a.Logs = sink
127 b.startErr = errors.New("listen unix /data/exec/h/inst.sock: bind: address already in use")
128 rt := testRoute("ledgerpay.bde.oncarlos.com")
129 rt.App = "ledgerpay"
130 rt.Slug = ""
131 if _, err := a.beginRequest(context.Background(), rt); err == nil {
132 t.Fatal("wake should have failed")
133 }
134 var wakeFails []eventRec
135 for _, e := range sink.events {
136 if strings.Contains(e.msg, "wake failed") {
137 wakeFails = append(wakeFails, e)
138 }
139 }
140 if len(wakeFails) != 1 {
141 t.Fatalf("wake-failure events: %d (%v)", len(wakeFails), sink.events)
142 }
143 ev := wakeFails[0]
144 if ev.level != "error" || ev.host != "ledgerpay.bde.oncarlos.com" || ev.app != "ledgerpay" {
145 t.Fatalf("event mis-tagged: %+v", ev)
146 }
147 if strings.Contains(ev.msg, "/data") || !strings.Contains(ev.msg, "socket already in use") {
148 t.Fatalf("event not member-safe: %q", ev.msg)
149 }
150
151 // Success: a woke event, tagged the same way.
152 a2, _, _, _ := testActivator(t, time.Hour)
153 sink2 := &fakeSink{}
154 a2.Logs = sink2
155 rel, err := a2.beginRequest(context.Background(), rt)
156 if err != nil {
157 t.Fatalf("wake failed: %v", err)
158 }
159 rel()
160 var woke bool
161 for _, e := range sink2.events {
162 if strings.Contains(e.msg, "instance woke") && e.level == "info" {
163 woke = true
164 }
165 }
166 if !woke {
167 t.Fatalf("no woke event: %+v", sink2.events)
168 }
169}
170
171// A claims-on wake on the closed launch fails before its exec hook, and
172// startAdmitted wraps that in errClaimsAdmission so the one classifier
173// reads it as the admission's (Task 10). The wrapper is internal: the
174// member's event renders exactly as the bare cause did on main, whatever
175// the flag, alone or joined with FailedAdmission's error as doClaimWake
176// returns it. No "claims admission" text reaches a member.
177func TestMemberSafeWakeReasonHidesTheClaimsAdmissionWrapper(t *testing.T) {
178 recovery := errors.New("recovery: release the admission")
179 for _, tc := range []struct {
180 name string
181 wrapped, bare error
182 }{
183 {"the closed launch", claimAdmission(ErrClaimIsolationUnavailable, false), ErrClaimIsolationUnavailable},
184 {"the closed launch, wrapped by the launcher", claimAdmission(fmt.Errorf("start main: %w", ErrClaimIsolationUnavailable), false),
185 fmt.Errorf("start main: %w", ErrClaimIsolationUnavailable)},
186 {"joined as doClaimWake returns it", errors.Join(claimAdmission(ErrClaimIsolationUnavailable, false), nil),
187 errors.Join(ErrClaimIsolationUnavailable, nil)},
188 {"joined with a recovery error", errors.Join(claimAdmission(ErrClaimIsolationUnavailable, false), recovery),
189 errors.Join(ErrClaimIsolationUnavailable, recovery)},
190 } {
191 got, want := memberSafeWakeReason(tc.wrapped), memberSafeWakeReason(tc.bare)
192 if got != want {
193 t.Errorf("%s: %q, want main's %q", tc.name, got, want)
194 }
195 if strings.Contains(got, "claims admission") {
196 t.Errorf("%s: the admission wrapper reached a member: %q", tc.name, got)
197 }
198 }
199}
200
201// Amadan #272: a wake refused because the host's parent cgroup holds a
202// process directly reads to a member as the maintenance fence's approved
203// text, not as cgroup detail.
204func TestMemberSafeWakeReasonHidesALegacyParentRefusal(t *testing.T) {
205 err := fmt.Errorf("h.example.com: %w: h.example.com holds 2 process(es)", cgroup.ErrLegacyParentPopulated)
206 got := memberSafeWakeReason(err)
207 if got != memberSafeWakeReason(ErrFenced) || strings.Contains(got, "cgroup") || strings.Contains(got, "process") {
208 t.Errorf("member reason = %q, want the fence text alone", got)
209 }
210}
211
212// Amadan #272, Task 19: a wake refused because its cgroup took cgroup.kill
213// and could not be made fresh reads to a member as the maintenance fence's
214// approved text, not as cgroup detail. Nothing of the build ran, so the
215// wake is never one that may have started (startMayHaveRun).
216func TestMemberSafeWakeReasonHidesAKilledCgroupRefusal(t *testing.T) {
217 err := fmt.Errorf("%w: /sys/fs/cgroup/x/carlos-exec@h.example.com: device or resource busy", cgroup.ErrKilledCgroupKept)
218 got := memberSafeWakeReason(err)
219 if got != memberSafeWakeReason(ErrFenced) || strings.Contains(got, "cgroup") || strings.Contains(got, "busy") {
220 t.Errorf("member reason = %q, want the fence text alone", got)
221 }
222 if startMayHaveRun(nil, err) {
223 t.Error("a start refused for a killed cgroup counted as one that may have run")
224 }
225}
226
227// Task 7 (readiness round 2): a wake refused because its generation's limits
228// could not be applied reads to a member as the maintenance fence's approved
229// text, not as cgroup detail. Nothing of the build ran.
230func TestMemberSafeWakeReasonHidesALimitNotAppliedRefusal(t *testing.T) {
231 for _, err := range []error{
232 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),
233 fmt.Errorf("%w: h.example.com: the placement shim is not available", cgroup.ErrLimitNotApplied),
234 } {
235 got := memberSafeWakeReason(err)
236 if got != memberSafeWakeReason(ErrFenced) || strings.Contains(got, "cgroup") || strings.Contains(got, "memory") {
237 t.Errorf("member reason = %q, want the fence text alone", got)
238 }
239 if startMayHaveRun(nil, err) {
240 t.Error("a start refused for its limits counted as one that may have run")
241 }
242 }
243}
244
245// Whole-branch review round 1 of zdd readiness 3, finding 3. A start whose
246// exec.CommandContext context ends while the cgroup manager's shim is being
247// placed answers the context's own error, bare, as exec.Cmd.Start answers
248// an ended context (cgroup's startIn; its unit and kernel tests pin that
249// shape). The only spawn built with a context is the seed, so this is that
250// error as the seed wraps it and the wake event renders it: the classifier
251// reads a cancellation as the caller's own end, and the member reads what a
252// direct start's ended context always read, with no placement, shim or
253// cgroup words (those stay in the agent's log line) and no new text.
254func TestCancelledPlacementReadsAsTheCallersOwnEnd(t *testing.T) {
255 const host = "h.example.com"
256 cancelled := fmt.Errorf("seeding %s: %w", host, context.Canceled)
257 timedOut := fmt.Errorf("seeding %s: %w", host, fmt.Errorf("timed out after %s and was killed: %w", seedTimeout, context.DeadlineExceeded))
258 if got := classifyStep(nil, cancelled); got != stepCancelled {
259 t.Errorf("classifyStep(cancelled placement) = %v, want stepCancelled", got)
260 }
261 if got, want := classifyStep(nil, timedOut), classifyStep(nil, context.DeadlineExceeded); got != want {
262 t.Errorf("classifyStep(timed-out placement) = %v, want %v, as for a direct start's deadline", got, want)
263 }
264 for err, want := range map[error]string{
265 cancelled: "wake failed: seeding h.example.com: context canceled",
266 timedOut: "wake failed: seeding h.example.com: timed out after " + seedTimeout.String() + " and was killed: context deadline exceeded",
267 } {
268 got := memberSafeWakeReason(err)
269 if got != want {
270 t.Errorf("member reason = %q, want %q", got, want)
271 }
272 for _, word := range []string{"placement", "shim", "cgroup"} {
273 if strings.Contains(got, word) {
274 t.Errorf("member reason %q carries %q", got, word)
275 }
276 }
277 if startMayHaveRun(nil, err) {
278 t.Errorf("a start whose context ended during placement counted as one that may have run: %v", err)
279 }
280 }
281}
282