carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "errors"
6 "log"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostactors"
15 "github.com/carlosframework/platform/internal/hostbox"
16 "github.com/carlosframework/platform/internal/hostclaim"
17 execlease "github.com/carlosframework/platform/internal/hostexec"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "github.com/carlosframework/platform/internal/hostwrite"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23type claimParticipation struct {
24 Binding execlease.Binding
25 Controller claimExecutionController
26 Ready chan struct{}
27 Err error
28 Config execlease.Config
29 Captured hostclaim.Observed
30 CleanupSucceeded bool
31 // AckController is the controller ID this slot's controller recorded in
32 // its first execution acknowledgement. Only that acknowledgement is ever
33 // refreshed: a restarted agent opens a new slot and leaves an older
34 // controller's acknowledgement alone. Guarded by productionClaimLocal.mu.
35 AckController string
36}
37type claimExecutionController interface {
38 Ready(context.Context) (execlease.State, error)
39 Close(context.Context) error
40}
41
42type productionClaimLocal struct {
43 executionCall func(context.Context, execlease.ExecutionRequest) (execlease.ExecutionResponse, error)
44 serving map[string]execlease.ExecutionRequest
45 legacyParticipation bool // Only legacy-controller fixtures select this path.
46
47 executionRead func(context.Context, *hostclaim.Claim) (execlease.ExecutionRecord, error)
48 Resources ClaimRuntimeResources
49 Identity hostbox.IdentityProvider
50 Directory hostactors.Directory
51 Store release.Store
52 mu sync.Mutex
53 slots map[string]*claimParticipation
54 assembly *ClaimAssembly
55 lifetime context.Context
56 closed bool
57 loopCancel context.CancelFunc
58 loopDone chan struct{}
59 stopIdentity *claimStopLocal
60 // lockRoot is a test seam (region HA N12). Set, the participation's
61 // hostguard and hostexec locks live under it and its controllers skip
62 // the update-floor check (their UpdateGuardDir is empty; main's floor
63 // tests cover it). Empty is production: /run/carlos's locks and the
64 // floor under OptDir.
65 lockRoot string
66 // now is a test seam for the acknowledgement refresh's wall clock; nil
67 // is time.Now.
68 now func() time.Time
69}
70
71// lockDir is the named lock directory under lockRoot, or "" (the
72// package's default) when lockRoot is unset.
73func (p *productionClaimLocal) lockDir(name string) string {
74 if p.lockRoot == "" {
75 return ""
76 }
77 return filepath.Join(p.lockRoot, name)
78}
79
80// updateFloor is the controller's update-floor guard directory and
81// journal: production's under OptDir, none under the N12 test seam. No
82// production constructor sets lockRoot (OpenClaimOwners builds the owner
83// without it; only tests assign it), so the floor is never bypassed in
84// production.
85func (p *productionClaimLocal) updateFloor() (string, string) {
86 if p.lockRoot != "" {
87 return "", ""
88 }
89 return filepath.Join(p.Resources.OptDir, "system", "guard"), filepath.Join(p.Resources.OptDir, "system", "router", "journal.json")
90}
91
92func (p *productionClaimLocal) bind(ctx context.Context, a *ClaimAssembly) error {
93 local, err := a.localStopIdentity(ctx)
94 if err != nil {
95 return err
96 }
97 p.mu.Lock()
98 defer p.mu.Unlock()
99 p.assembly = a
100 p.lifetime = ctx
101 p.stopIdentity = &local
102 return nil
103}
104
105// Prepare performs lease I/O before WorkSource takes hostguard. Serving never
106// opens a lease. A failed controller is retained as denial until cleanup and
107// CC9 positively discharge it, never replaced by age.
108func (p *productionClaimLocal) Prepare(ctx context.Context, o hostclaim.Observed) error {
109 if o.Claim == nil || (!o.Claim.Free() && o.Claim.Holder == nil) {
110 return hostbox.ErrChanged
111 }
112 actor, err := p.Identity.Verify(ctx)
113 if err != nil {
114 return err
115 }
116 return p.prepareWith(ctx, actor, o)
117}
118func (p *productionClaimLocal) prepareLegacyWith(ctx context.Context, actor hostbox.Actor, o hostclaim.Observed) error {
119 var err error
120 c := o.Claim
121 p.mu.Lock()
122 old := p.slots[o.Host]
123 a := p.assembly
124 lifetime := p.lifetime
125 closed := p.closed
126 p.mu.Unlock()
127 if closed || a == nil || lifetime == nil {
128 return hostwrite.ErrUnavailable
129 }
130 if p.Resources.Mode != "agent" {
131 return p.checkRootParticipation(ctx, actor, c)
132 }
133 if c.Op.Kind == hostclaim.KindServing || c.Free() || !slices.Contains(c.Op.Participants, actor.Box) {
134
135 if old == nil {
136 state, err := execlease.InspectLocal(p.Resources.DataDir, o.Host)
137 if errors.Is(err, os.ErrNotExist) {
138 return nil
139 }
140 if err != nil {
141 return err
142 }
143 return p.clearUntracked(ctx, actor, o, state, execlease.Config{Binding: state.Binding, Guard: hostguard.Guard{DataDir: p.Resources.DataDir, LockDir: p.lockDir("hostguard"), Claims: hostguard.StoreReader{Store: p.Store}}, LockDir: p.lockDir("hostexec")})
144 }
145 select {
146 case <-old.Ready:
147 case <-ctx.Done():
148 return ctx.Err()
149 }
150 if old.Controller != nil {
151 // Close joins before it reports cleanup or conditional-release
152 // errors. A timed-out join retains the slot and lifetime lock.
153 closeErr := old.Controller.Close(ctx)
154 if ctx.Err() != nil {
155 return errors.Join(closeErr, ctx.Err())
156 }
157 }
158 // A previous failed cleanup remains denial until this fresh positive
159 // proof succeeds. Its historical error must not veto CC9 forever.
160 if !old.CleanupSucceeded {
161 if err = p.cleanup(ctx, old.Captured, old.Config.Binding); err != nil {
162 return err
163 }
164 }
165
166 if err = execlease.ClearStale(ctx, old.Config); err != nil {
167 return err
168 }
169 p.mu.Lock()
170 if p.slots[o.Host] == old {
171 delete(p.slots, o.Host)
172 }
173 p.mu.Unlock()
174 return nil
175 }
176 if old != nil {
177 select {
178 case <-old.Ready:
179 case <-ctx.Done():
180 return ctx.Err()
181 }
182 if old.Err != nil {
183 return old.Err
184 }
185 if old.Binding.Op != c.Op.ID || old.Binding.Incarnation != c.Holder.Incarnation {
186 return execlease.ErrLost
187 }
188 _, err = old.Controller.Ready(ctx)
189 return err
190 }
191 boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
192 if err != nil {
193 return err
194 }
195 slot := &claimParticipation{Ready: make(chan struct{}), Captured: o}
196 p.mu.Lock()
197 if p.closed || p.slots[o.Host] != nil {
198 p.mu.Unlock()
199 return execlease.ErrControllerBusy
200 }
201 p.slots[o.Host] = slot
202 p.mu.Unlock()
203 binding := execlease.Binding{Box: actor.Box, Enrollment: actor.Enrollment, Host: o.Host, Incarnation: c.Holder.Incarnation, Op: c.Op.ID, BootID: strings.TrimSpace(string(boot))}
204 floor, journal := p.updateFloor()
205 slot.Config = execlease.Config{Binding: binding, Store: p.Store, Guard: hostguard.Guard{DataDir: p.Resources.DataDir, LockDir: p.lockDir("hostguard"), Claims: hostguard.StoreReader{Store: p.Store}}, Directory: p.Directory, Clock: execlease.SystemClock{}, UpdateGuardDir: floor, UpdateJournal: journal, LockDir: p.lockDir("hostexec"),
206 CloseAdmission: func() { p.cancelWork(o.Host) }, CancelWork: func() { p.cancelWork(o.Host) }, Cleanup: func(ctx context.Context) error {
207 err := p.cleanup(ctx, o, binding)
208 slot.CleanupSucceeded = err == nil
209 return err
210 }}
211 slot.Controller, slot.Err = execlease.Open(lifetime, slot.Config)
212 if slot.Err == nil {
213 var state execlease.State
214 state, slot.Err = slot.Controller.Ready(ctx)
215 slot.Binding = state.Binding
216 }
217 close(slot.Ready)
218 return slot.Err
219}
220func (p *productionClaimLocal) cancelWork(host string) {
221 p.mu.Lock()
222 a := p.assembly
223 p.mu.Unlock()
224 if a != nil && a.Wake != nil {
225 if pending := a.Wake.get(host); pending != nil {
226 pending.cancel()
227 }
228 }
229}
230func (p *productionClaimLocal) cleanup(ctx context.Context, o hostclaim.Observed, binding execlease.Binding) error {
231 p.mu.Lock()
232 a := p.assembly
233 local := p.stopIdentity
234 p.mu.Unlock()
235 if a != nil && local != nil {
236 copy := *a
237 saved := *local
238 copy.stopLocal = func(context.Context) (claimStopLocal, error) { return saved, nil }
239 a = &copy
240 }
241 if a == nil || a.Wake == nil || a.Launches == nil || a.Reconcile == nil || a.Reconcile.Census == nil {
242 return hostwrite.ErrUnavailable
243 }
244 p.cancelWork(o.Host)
245 if !p.legacyParticipation {
246 if done, err := a.completedRetirementDischarge(ctx, o, binding); done || err != nil {
247 return err
248 }
249 }
250 // Claim ask C10 (region HA rulings 56, 66): a relocation that completed
251 // on this box is discharged by the persisted participation binding and
252 // its exact completed probe evidence, before any ordinary cleanup, so
253 // no serving-shaped stop ever replaces the probe's fence. The captured
254 // observation is not consulted: after a restart it is the current one.
255 if done, err := a.completedRelocationDischarge(ctx, binding); done || err != nil {
256 return err
257 }
258 // Cleanup uses the journal and producer set directly. It must never call
259 // WorkSource.Select: Open is still waiting for this cleanup to finish.
260 has, err := a.Launches.HasHost(ctx, o.Host)
261 if err != nil {
262 return err
263 }
264 if has {
265 if o.Claim == nil || o.Claim.Holder == nil || o.Claim.Holder.Session == 0 {
266 return ErrClaimStopInventory
267 }
268 return a.stopCapturedSession(ctx, o)
269 }
270 if o.Claim == nil || o.Claim.Holder == nil {
271 return ErrClaimStopInventory
272 }
273 if !p.legacyParticipation && a.Launches.Remote() && a.Reconcile.Inventory.Remote() {
274 return ctx.Err()
275 }
276 return a.Reconcile.Census.CheckLocal(ctx, o, hostbox.RecordSelection{Host: o.Host, Incarnation: o.Claim.Holder.Incarnation})
277}
278func (p *productionClaimLocal) checkLegacy(ctx context.Context, a hostbox.Actor, c *hostclaim.Claim, s hostbox.Selection) error {
279 if ctx.Err() != nil {
280 return ctx.Err()
281 }
282 if c == nil || c.Holder == nil || a.Box != s.Box || a.Region != s.Region || c.Holder.Incarnation != s.Captured.Incarnation || slices.Contains(c.Lost, a.Box) {
283 return hostbox.ErrChanged
284 }
285 p.mu.Lock()
286 slot := p.slots[c.Host]
287 closed := p.closed
288 p.mu.Unlock()
289 if closed {
290 return execlease.ErrLost
291 }
292 if p.Resources.Mode != "agent" {
293 return p.checkRootParticipation(ctx, a, c)
294 }
295 if c.Op.Kind == hostclaim.KindServing {
296 if slot != nil {
297 return execlease.ErrLost
298 }
299 _, err := execlease.InspectLocal(p.Resources.DataDir, c.Host)
300 if !errors.Is(err, os.ErrNotExist) {
301 return errors.Join(execlease.ErrLost, err)
302 }
303 return nil
304 }
305 if slot == nil {
306 state, err := execlease.InspectLocal(p.Resources.DataDir, c.Host)
307 if err != nil {
308 return err
309 }
310 if state.Op != c.Op.ID || state.Incarnation != c.Holder.Incarnation || state.Box != a.Box || state.Enrollment != a.Enrollment {
311 return execlease.ErrState
312 }
313 return execlease.CheckLocal(p.Resources.DataDir, state.Binding, execlease.SystemClock{}, "")
314 }
315 select {
316 case <-slot.Ready:
317 default:
318 return execlease.ErrInactive
319 }
320 if slot.Err != nil {
321 return slot.Err
322 }
323 if slot.Binding.Op != c.Op.ID || slot.Binding.Incarnation != c.Holder.Incarnation || slot.Binding.Box != a.Box || slot.Binding.Enrollment != a.Enrollment {
324 return execlease.ErrLost
325 }
326 return execlease.CheckLocal(p.Resources.DataDir, slot.Binding, execlease.SystemClock{}, "")
327}
328func (p *productionClaimLocal) Close(ctx context.Context) error {
329 p.mu.Lock()
330 p.closed = true
331 cancel, done := p.loopCancel, p.loopDone
332 slots := make([]*claimParticipation, 0, len(p.slots))
333 for _, s := range p.slots {
334 slots = append(slots, s)
335 }
336 p.mu.Unlock()
337 if cancel != nil {
338 cancel()
339 select {
340 case <-done:
341 case <-ctx.Done():
342 return ctx.Err()
343 }
344 }
345 var errs []error
346 for _, slot := range slots {
347 select {
348 case <-slot.Ready:
349 case <-ctx.Done():
350 return errors.Join(errors.Join(errs...), ctx.Err())
351 }
352 if slot.Controller != nil {
353 errs = append(errs, slot.Controller.Close(ctx))
354 }
355 errs = append(errs, slot.Err)
356 }
357 return errors.Join(errs...)
358}
359
360// start observes participants even when they have no pending local writes.
361// Root and adopt only inspect the agent's controller and never own a lease.
362func (p *productionClaimLocal) start(ctx context.Context) {
363 if p.Resources.Mode != "agent" {
364 return
365 }
366 worker, cancel := context.WithCancel(ctx)
367 p.mu.Lock()
368 if p.closed || p.loopCancel != nil {
369 p.mu.Unlock()
370 cancel()
371 return
372 }
373 p.loopCancel, p.loopDone = cancel, make(chan struct{})
374 done := p.loopDone
375 p.mu.Unlock()
376 go func() {
377 defer close(done)
378 ticker := time.NewTicker(30 * time.Second)
379 defer ticker.Stop()
380 for {
381 err := p.participate(worker)
382 if err != nil && worker.Err() == nil {
383 log.Printf("host claim participation: %v", err)
384 }
385 select {
386 case <-worker.Done():
387 return
388 case <-ticker.C:
389 }
390 }
391 }()
392}
393func (p *productionClaimLocal) participate(ctx context.Context) error {
394 identity, finishIdentity := context.WithTimeout(ctx, 30*time.Second)
395 actor, err := p.Identity.Verify(identity)
396 finishIdentity()
397 if err != nil {
398 return err
399 }
400 discovery, finishDiscovery := context.WithTimeout(ctx, 30*time.Second)
401 keys, err := p.Store.List(discovery, "control/hosts/")
402 finishDiscovery()
403 if err != nil {
404 return err
405 }
406 var errs []error
407 for _, key := range keys {
408 host := strings.TrimSuffix(strings.TrimPrefix(key, "control/hosts/"), ".json")
409 canonical, err := hostclaim.Key(host)
410 if err != nil || canonical != key {
411 continue
412 }
413 // Each host gets a fresh budget from the worker, including its claim read.
414 // One slow earlier host cannot consume every later host's next pass.
415 bounded, cancel := context.WithTimeout(ctx, hostbox.RequestLimit)
416 err = p.participateHost(bounded, actor, host)
417 cancel()
418 if err != nil {
419 errs = append(errs, err)
420 }
421 if ctx.Err() != nil {
422 return errors.Join(errors.Join(errs...), ctx.Err())
423 }
424 }
425 return errors.Join(errs...)
426}
427func (p *productionClaimLocal) participateHost(ctx context.Context, actor hostbox.Actor, host string) error {
428 o, err := hostclaim.Read(ctx, p.Store, host)
429 if err != nil {
430 return err
431 }
432 if o.Absent || o.Claim == nil {
433 return nil
434 }
435 if p.legacyParticipation {
436 p.mu.Lock()
437 tracked := p.slots[host] != nil
438 p.mu.Unlock()
439 if !tracked && (o.Claim.Op.Kind == hostclaim.KindServing || !slices.Contains(o.Claim.Op.Participants, actor.Box)) {
440 if _, err := execlease.InspectLocal(p.Resources.DataDir, host); errors.Is(err, os.ErrNotExist) {
441 return nil
442 }
443 }
444 }
445 if err := p.prepareWith(ctx, actor, o); err != nil {
446 return err
447 }
448 if p.legacyParticipation {
449 return nil
450 }
451 return p.acknowledgeOperation(ctx, actor, o)
452}
453