hostexec, hostguard, lease: the durable execution controller and independent writer checks (claim Plan 8 Task 3)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+2006
−0
internal/hostexec/clock_linux.go+32 −0internal/hostexec/clock_other.go+11 −0internal/hostexec/clock_other_test.go+17 −0internal/hostexec/controller.go+626 −0internal/hostexec/controller_test.go+441 −0internal/hostexec/local_other.go+17 −0internal/hostexec/local_unix.go+197 −0internal/hostexec/record.go+94 −0internal/hostexec/review_test.go+460 −0internal/hostguard/local.go+13 −0internal/hostguard/local_test.go+45 −0internal/lease/execution_store.go+20 −0internal/lease/execution_store_test.go+33 −0
diff --git a/internal/hostexec/clock_linux.go b/internal/hostexec/clock_linux.go| new file mode 100644 |
| index 000000000..edb44e56a |
| --- /dev/null |
| +++ b/internal/hostexec/clock_linux.go |
| @@ -0,0 +1,32 @@ |
| +//go:build linux |
| + |
| +package hostexec |
| + |
| +import ( |
| + "golang.org/x/sys/unix" |
| + "os" |
| + "strings" |
| + "time" |
| +) |
| + |
| +type SystemClock struct{} |
| + |
| +func (SystemClock) Now() (time.Time, int64, error) { |
| + var ts unix.Timespec |
| + if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &ts); err != nil { |
| + return time.Time{}, 0, err |
| + } |
| + return time.Now(), ts.Nano(), nil |
| +} |
| +func (SystemClock) After(d time.Duration) <-chan time.Time { return time.After(d) } |
| +func BootID() (string, error) { |
| + b, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + return "", err |
| + } |
| + id := strings.TrimSpace(string(b)) |
| + if !validID(id) { |
| + return "", ErrState |
| + } |
| + return id, nil |
| +} |
diff --git a/internal/hostexec/clock_other.go b/internal/hostexec/clock_other.go| new file mode 100644 |
| index 000000000..7809dcee3 |
| --- /dev/null |
| +++ b/internal/hostexec/clock_other.go |
| @@ -0,0 +1,11 @@ |
| +//go:build !linux |
| + |
| +package hostexec |
| + |
| +import "time" |
| + |
| +type SystemClock struct{} |
| + |
| +func (SystemClock) Now() (time.Time, int64, error) { return time.Time{}, 0, ErrUnsupported } |
| +func (SystemClock) After(d time.Duration) <-chan time.Time { return time.After(d) } |
| +func BootID() (string, error) { return "", ErrUnsupported } |
diff --git a/internal/hostexec/clock_other_test.go b/internal/hostexec/clock_other_test.go| new file mode 100644 |
| index 000000000..381ffd8d4 |
| --- /dev/null |
| +++ b/internal/hostexec/clock_other_test.go |
| @@ -0,0 +1,17 @@ |
| +//go:build !linux |
| + |
| +package hostexec |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestSystemClockRefusesUnsupportedPlatform(t *testing.T) { |
| + if _, _, err := (SystemClock{}).Now(); !errors.Is(err, ErrUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if _, err := BootID(); !errors.Is(err, ErrUnsupported) { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/hostexec/controller.go b/internal/hostexec/controller.go| new file mode 100644 |
| index 000000000..6f1bf820d |
| --- /dev/null |
| +++ b/internal/hostexec/controller.go |
| @@ -0,0 +1,626 @@ |
| +package hostexec |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "slices" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostquarantine" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type Config struct { |
| + Binding Binding |
| + Store release.Store |
| + Guard hostguard.Guard |
| + Directory hostquarantine.ActorDirectory |
| + Clock Clock |
| + LockDir string |
| + // These close/cancel hooks must be nonblocking and safe to call repeatedly. |
| + CloseAdmission func() |
| + CancelWork func() |
| + // Cleanup terminates only this host's processes and closes its replicators. |
| + // It must not wait for the host guard: uncertain disk writes retain that |
| + // guard to deny other processes, while termination must proceed independently. |
| + // Its failure remains a separate denial owned by the caller, even after CC9. |
| + Cleanup func(context.Context) error |
| + writeLocal func(*localDir, State) error // test seam; nil uses durable descriptor-relative I/O |
| +} |
| + |
| +type Controller struct { |
| + cfg Config |
| + mu sync.Mutex |
| + state State |
| + err error |
| + cleanupErr error |
| + releaseETag string |
| + cleanupOnce sync.Once |
| + cleanupDone chan struct{} |
| + ready chan struct{} |
| + readyOnce sync.Once |
| + lost chan struct{} |
| + ctx context.Context |
| + cancel context.CancelFunc |
| + done chan struct{} |
| + watchDone chan struct{} |
| + unlock func() |
| + unlive func() |
| + closeMu sync.Mutex |
| + closed bool |
| +} |
| + |
| +func Open(ctx context.Context, cfg Config) (*Controller, error) { |
| + if cfg.Clock == nil { |
| + cfg.Clock = SystemClock{} |
| + } |
| + if cfg.LockDir == "" { |
| + cfg.LockDir = "/run/carlos/hostexec" |
| + } |
| + var nonce [16]byte |
| + if _, err := rand.Read(nonce[:]); err != nil { |
| + return nil, err |
| + } |
| + cfg.Binding.ControllerID = hex.EncodeToString(nonce[:]) |
| + if !cfg.Binding.valid() || cfg.Store == nil || cfg.Guard.Claims == nil || cfg.Directory == nil || |
| + cfg.CloseAdmission == nil || cfg.CancelWork == nil || cfg.Cleanup == nil { |
| + return nil, ErrState |
| + } |
| + wall, boot, err := cfg.Clock.Now() |
| + if err != nil { |
| + return nil, err |
| + } |
| + cctx, cancel := context.WithCancel(ctx) |
| + c := &Controller{cfg: cfg, state: State{V: 1, Binding: cfg.Binding, State: "pending", Expires: wall.Add(TTL), SafeUntil: boot + int64(TTL-Margin)}, ctx: cctx, cancel: cancel, |
| + ready: make(chan struct{}), lost: make(chan struct{}), done: make(chan struct{}), watchDone: make(chan struct{}), cleanupDone: make(chan struct{})} |
| + // Hold the host guard across lifetime-lock acquisition and the pending |
| + // write. Acquire the live lock only after pending state is durable, so |
| + // orphaned active bytes cannot regain admission. Never wait for the |
| + // lifetime lock while holding the guard. |
| + var cleaned <-chan error |
| + for { |
| + err = cfg.Guard.WithLocal(ctx, cfg.Binding.Host, func() error { |
| + var lockErr error |
| + c.unlock, lockErr = tryAcquire(cfg.LockDir, cfg.Binding.Host) |
| + if lockErr != nil { |
| + return lockErr |
| + } |
| + // Start cleanup before any persistence can block. Exclusion stays |
| + // held until this independent cleanup has joined on every exit. |
| + result := make(chan error, 1) |
| + cleaned = result |
| + go func() { result <- cfg.Cleanup(context.Background()) }() |
| + cfg.CloseAdmission() |
| + cfg.CancelWork() |
| + d, openErr := openLocal(cfg.Guard.DataDir) |
| + if openErr != nil { |
| + return openErr |
| + } |
| + defer d.close() |
| + if writeErr := c.write(d, c.state); writeErr != nil { |
| + return writeErr |
| + } |
| + c.unlive, lockErr = tryAcquireLive(cfg.LockDir, cfg.Binding.Host) |
| + return lockErr |
| + }) |
| + if c.unlock != nil || !errors.Is(err, ErrControllerBusy) { |
| + break |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + err = ctx.Err() |
| + case <-time.After(10 * time.Millisecond): |
| + continue |
| + } |
| + break |
| + } |
| + if err == nil { |
| + err = <-cleaned |
| + cleaned = nil |
| + } |
| + if err == nil { |
| + err = c.authority(ctx) |
| + } |
| + if err != nil { |
| + c.state.State = "lost" |
| + hadLive := c.unlive != nil |
| + if c.unlive != nil { |
| + c.unlive() |
| + c.unlive = nil |
| + } |
| + if c.unlock != nil { |
| + // Before live-lock acquisition, no local state can authorize work. |
| + // A failed pending write (including an unsafe path) must not make |
| + // cleanup wait for another write to the same failed destination. |
| + if hadLive { |
| + err = errors.Join(err, c.save(c.state)) |
| + } |
| + if cleaned != nil { |
| + err = errors.Join(err, <-cleaned) |
| + } |
| + c.unlock() |
| + } |
| + cancel() |
| + return nil, err |
| + } |
| + go c.watch() |
| + go c.run() |
| + return c, nil |
| +} |
| + |
| +func (c *Controller) write(d *localDir, s State) error { |
| + if c.cfg.writeLocal != nil { |
| + return c.cfg.writeLocal(d, s) |
| + } |
| + return d.write(s) |
| +} |
| + |
| +// save retains the guard until lost state is durable, including failures before |
| +// rename. The revoked live lock denies CheckLocal while the old file survives. |
| +func (c *Controller) save(s State) error { |
| + return c.cfg.Guard.WithLocal(context.Background(), s.Host, func() error { |
| + for { |
| + d, err := openLocal(c.cfg.Guard.DataDir) |
| + if err == nil { |
| + err = c.write(d, s) |
| + d.close() |
| + } |
| + if err == nil || s.State != "lost" { |
| + return err |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| + }) |
| +} |
| +func (c *Controller) authority(ctx context.Context) error { |
| + b := c.cfg.Binding |
| + if err := hostquarantine.Check(ctx, c.cfg.Directory, b.Box, b.Enrollment); err != nil { |
| + return err |
| + } |
| + obs, err := c.cfg.Guard.Claims.Read(ctx, b.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent || obs.Claim == nil || obs.Host != b.Host || obs.Claim.Host != b.Host { |
| + return ErrInactive |
| + } |
| + q := obs.Claim |
| + if q.Validate() != nil || q.Holder == nil || q.Holder.Incarnation != b.Incarnation || q.Op.ID != b.Op || |
| + q.Op.Kind == hostclaim.KindServing || q.Free() || !slices.Contains(q.Op.Participants, b.Box) || slices.Contains(q.Lost, b.Box) { |
| + return ErrInactive |
| + } |
| + return nil |
| +} |
| + |
| +// loseLocked latches denial before attempting disk I/O or process cleanup. |
| +func (c *Controller) loseLocked(err error) { |
| + if c.err != nil { |
| + return |
| + } |
| + c.err = err |
| + c.state.State = "lost" |
| + // Revoke cross-process admission before publishing loss or starting I/O. |
| + // This close needs no disk space. The lifetime lock remains held. |
| + if c.unlive != nil { |
| + c.unlive() |
| + c.unlive = nil |
| + } |
| + close(c.lost) |
| + c.cancel() |
| + c.cfg.CloseAdmission() |
| + c.cfg.CancelWork() |
| + c.readyOnce.Do(func() { close(c.ready) }) |
| + s := c.state |
| + c.cleanupOnce.Do(func() { |
| + go func() { |
| + // Neither a blocked disk nor blocked cleanup can prevent the other. |
| + cleaned := make(chan error, 1) |
| + go func() { cleaned <- c.cfg.Cleanup(context.Background()) }() |
| + persistErr := c.save(s) |
| + cleanupErr := <-cleaned |
| + c.mu.Lock() |
| + c.cleanupErr = errors.Join(persistErr, cleanupErr) |
| + c.mu.Unlock() |
| + close(c.cleanupDone) |
| + }() |
| + }) |
| +} |
| +func (c *Controller) checkLocked() error { |
| + if c.err != nil { |
| + return c.err |
| + } |
| + _, boot, err := c.cfg.Clock.Now() |
| + if err != nil { |
| + c.loseLocked(err) |
| + } else if boot >= c.state.SafeUntil { |
| + c.loseLocked(ErrLost) |
| + } |
| + return c.err |
| +} |
| +func (c *Controller) Lost() <-chan struct{} { return c.lost } |
| +func (c *Controller) Ready(ctx context.Context) (State, error) { |
| + select { |
| + case <-ctx.Done(): |
| + return State{}, ctx.Err() |
| + case <-c.ready: |
| + } |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if err := c.checkLocked(); err != nil { |
| + return State{}, err |
| + } |
| + if c.state.State != "active" { |
| + return State{}, ErrState |
| + } |
| + return c.state, nil |
| +} |
| +func (c *Controller) CleanupError() error { c.mu.Lock(); defer c.mu.Unlock(); return c.cleanupErr } |
| + |
| +func (c *Controller) watch() { |
| + defer close(c.watchDone) |
| + for { |
| + c.mu.Lock() |
| + err := c.checkLocked() |
| + c.mu.Unlock() |
| + if err != nil { |
| + return |
| + } |
| + // CLOCK_BOOTTIME is sampled on every wake and every admission. A normal Go |
| + // timer does not itself prove that a suspended machine has remained stopped. |
| + select { |
| + case <-c.ctx.Done(): |
| + c.mu.Lock() |
| + c.loseLocked(c.ctx.Err()) |
| + c.mu.Unlock() |
| + return |
| + case <-c.cfg.Clock.After(100 * time.Millisecond): |
| + } |
| + } |
| +} |
| +func (c *Controller) run() { |
| + defer close(c.done) |
| + key, _ := Key(c.cfg.Binding.Host, c.cfg.Binding.Box) |
| + body, etag, err := c.cfg.Store.Get(c.ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + err = nil |
| + etag = "" |
| + } else if err == nil { |
| + var r Record |
| + if decode(body, &r) != nil || r.Op == "" || r.Expires.IsZero() || etag == "" { |
| + err = ErrState |
| + } |
| + } |
| + if err != nil { |
| + c.mu.Lock() |
| + c.loseLocked(err) |
| + c.mu.Unlock() |
| + return |
| + } |
| + for { |
| + if err = c.authority(c.ctx); err != nil { |
| + c.mu.Lock() |
| + c.loseLocked(err) |
| + c.mu.Unlock() |
| + return |
| + } |
| + wall, start, clockErr := c.cfg.Clock.Now() |
| + if clockErr != nil { |
| + c.mu.Lock() |
| + c.loseLocked(clockErr) |
| + c.mu.Unlock() |
| + return |
| + } |
| + c.mu.Lock() |
| + err = c.checkLocked() |
| + captured := c.state.Binding |
| + c.mu.Unlock() |
| + if err != nil { |
| + return |
| + } |
| + proposed := Record{Op: captured.Op, Expires: wall.Add(TTL)} |
| + bytes, _ := json.Marshal(proposed) |
| + next, putErr := c.cfg.Store.Put(c.ctx, key, bytes, etag) |
| + c.mu.Lock() |
| + // A late acknowledgement can only contribute its exact ETag to release. |
| + if captured != c.state.Binding { |
| + c.mu.Unlock() |
| + return |
| + } |
| + if putErr == nil && next != "" { |
| + c.releaseETag = next |
| + etag = next |
| + } |
| + if err = c.checkLocked(); err == nil && (putErr != nil || next == "") { |
| + if putErr == nil { |
| + putErr = ErrState |
| + } |
| + c.loseLocked(putErr) |
| + err = putErr |
| + } |
| + c.mu.Unlock() |
| + if err != nil { |
| + return |
| + } |
| + // Network reads never hold either the controller mutex or the host guard. |
| + if err = c.authority(c.ctx); err != nil { |
| + c.mu.Lock() |
| + c.loseLocked(err) |
| + c.mu.Unlock() |
| + return |
| + } |
| + c.mu.Lock() |
| + if err = c.checkLocked(); err != nil { |
| + c.mu.Unlock() |
| + return |
| + } |
| + nextState := c.state |
| + nextState.State = "active" |
| + nextState.LeaseETag = next |
| + nextState.Expires = proposed.Expires |
| + nextState.SafeUntil = start + int64(TTL-Margin) |
| + // Never use a response-relative deadline, including when the wall clock moved. |
| + _, now, clockErr := c.cfg.Clock.Now() |
| + if clockErr != nil || now >= nextState.SafeUntil { |
| + c.loseLocked(ErrLost) |
| + c.mu.Unlock() |
| + return |
| + } |
| + c.mu.Unlock() |
| + // Publication and any intervening loss are serialized under the same host |
| + // guard, so another process never observes a stale active overwrite. |
| + if err = c.publish(nextState); err != nil { |
| + c.mu.Lock() |
| + c.loseLocked(err) |
| + c.mu.Unlock() |
| + return |
| + } |
| + _, now, clockErr = c.cfg.Clock.Now() |
| + if clockErr != nil { |
| + c.mu.Lock() |
| + c.loseLocked(clockErr) |
| + c.mu.Unlock() |
| + return |
| + } |
| + delay := time.Duration(start + int64(Renewal) - now) |
| + if delay < 0 { |
| + delay = 0 |
| + } |
| + select { |
| + case <-c.ctx.Done(): |
| + return |
| + case <-c.cfg.Clock.After(delay): |
| + } |
| + } |
| +} |
| + |
| +// publish leaves mu available to the watchdog during filesystem calls. If a |
| +// write has an uncertain outcome, it retains the host guard until denial is |
| +// durable; releasing it over possibly active bytes would admit another process. |
| +func (c *Controller) publish(next State) error { |
| + return c.cfg.Guard.WithLocal(context.Background(), next.Host, func() error { |
| + d, err := openLocal(c.cfg.Guard.DataDir) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.close() |
| + c.mu.Lock() |
| + err = c.checkLocked() |
| + if err == nil && next.Binding != c.state.Binding { |
| + c.loseLocked(ErrState) |
| + err = ErrState |
| + } |
| + c.mu.Unlock() |
| + if err != nil { |
| + return err |
| + } |
| + writeErr := c.write(d, next) |
| + c.mu.Lock() |
| + if writeErr != nil { |
| + c.loseLocked(writeErr) |
| + } |
| + err = c.checkLocked() |
| + if err == nil { |
| + _, boot, clockErr := c.cfg.Clock.Now() |
| + if clockErr != nil || boot >= next.SafeUntil { |
| + c.loseLocked(ErrLost) |
| + err = c.err |
| + } |
| + } |
| + if err == nil { |
| + c.state = next |
| + c.readyOnce.Do(func() { close(c.ready) }) |
| + c.mu.Unlock() |
| + return nil |
| + } |
| + denied := c.state |
| + denied.State = "lost" |
| + c.mu.Unlock() |
| + // Cancellation does not release the guard over uncertain active bytes. |
| + // Close may time out; it keeps the lifetime lock until this join completes. |
| + for { |
| + if persistErr := c.write(d, denied); persistErr == nil { |
| + return err |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| + }) |
| +} |
| + |
| +// Close cancels and joins all lease requests before conditional release. It |
| +// retains the lifetime lock on a timed-out join; call Close again to finish. |
| +func (c *Controller) Close(ctx context.Context) error { |
| + c.closeMu.Lock() |
| + defer c.closeMu.Unlock() |
| + if c.closed { |
| + return nil |
| + } |
| + c.mu.Lock() |
| + c.loseLocked(ErrLost) |
| + c.mu.Unlock() |
| + for _, done := range []<-chan struct{}{c.done, c.watchDone, c.cleanupDone} { |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-done: |
| + } |
| + } |
| + c.mu.Lock() |
| + etag := c.releaseETag |
| + c.mu.Unlock() |
| + var err error |
| + if etag != "" { |
| + key, _ := Key(c.cfg.Binding.Host, c.cfg.Binding.Box) |
| + err = release.DeleteIfMatch(ctx, c.cfg.Store, key, etag) |
| + } |
| + c.closed = true |
| + c.unlock() |
| + return errors.Join(err, c.CleanupError()) |
| +} |
| + |
| +// CheckLocal must run inside Guard.With, before each bounded host write. It |
| +// does not grant authority and never clears quarantine or cleanup denial. |
| +func CheckLocal(dataDir string, expected Binding, clock Clock, lockDir string) error { |
| + if !expected.valid() || clock == nil { |
| + return ErrState |
| + } |
| + if lockDir == "" { |
| + lockDir = "/run/carlos/hostexec" |
| + } |
| + // Guard.With serializes these probes with other writers and Open. A free |
| + // live lock denies even if ENOSPC left a future-dated active file behind. |
| + for _, probe := range []func(string, string) (func(), error){tryAcquire, tryAcquireLive} { |
| + unlock, err := probe(lockDir, expected.Host) |
| + if err == nil { |
| + unlock() |
| + return ErrLost |
| + } |
| + if !errors.Is(err, ErrControllerBusy) { |
| + return err |
| + } |
| + } |
| + d, err := openLocal(dataDir) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.close() |
| + s, err := d.read(expected.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if s.Binding != expected || s.State != "active" { |
| + return ErrLost |
| + } |
| + _, boot, err := clock.Now() |
| + if err != nil { |
| + return err |
| + } |
| + if boot >= s.SafeUntil { |
| + return ErrLost |
| + } |
| + return nil |
| +} |
| + |
| +// ClearStale performs CC9 with the lifetime lock held, after any old controller |
| +// has joined. A failed or mismatched claim read leaves local denial intact. |
| +func ClearStale(ctx context.Context, cfg Config) error { |
| + lockDir := cfg.LockDir |
| + if lockDir == "" { |
| + lockDir = "/run/carlos/hostexec" |
| + } |
| + if cfg.Guard.Claims == nil || !validID(cfg.Binding.Box) || !validID(cfg.Binding.Enrollment) { |
| + return ErrState |
| + } |
| + var unlock func() |
| + var err error |
| + for { |
| + err = cfg.Guard.WithLocal(ctx, cfg.Binding.Host, func() error { |
| + var lockErr error |
| + unlock, lockErr = tryAcquire(lockDir, cfg.Binding.Host) |
| + if lockErr != nil { |
| + return lockErr |
| + } |
| + denied := false |
| + defer func() { |
| + if !denied { |
| + unlock() |
| + unlock = nil |
| + } |
| + }() |
| + d, openErr := openLocal(cfg.Guard.DataDir) |
| + if openErr != nil { |
| + return openErr |
| + } |
| + defer d.close() |
| + old, readErr := d.read(cfg.Binding.Host) |
| + if errors.Is(readErr, os.ErrNotExist) { |
| + denied = true |
| + return nil |
| + } |
| + if readErr != nil { |
| + return readErr |
| + } |
| + if old.Box != cfg.Binding.Box || old.Enrollment != cfg.Binding.Enrollment || |
| + old.Op != cfg.Binding.Op || old.Incarnation != cfg.Binding.Incarnation { |
| + return ErrState |
| + } |
| + // Holding the lifetime lock must never make an orphaned active record |
| + // appear to belong to a running controller during the claim read. |
| + old.State = "lost" |
| + writeErr := d.write(old) |
| + denied = writeErr == nil |
| + return writeErr |
| + }) |
| + if !errors.Is(err, ErrControllerBusy) { |
| + break |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-time.After(10 * time.Millisecond): |
| + } |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + obs, err := cfg.Guard.Claims.Read(ctx, cfg.Binding.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent || obs.Claim == nil || obs.Host != cfg.Binding.Host || obs.Claim.Host != cfg.Binding.Host || obs.Claim.Validate() != nil { |
| + return ErrState |
| + } |
| + q := obs.Claim |
| + return cfg.Guard.WithLocal(ctx, cfg.Binding.Host, func() error { |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.close() |
| + old, err := d.read(cfg.Binding.Host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if old.Box != cfg.Binding.Box || old.Enrollment != cfg.Binding.Enrollment || |
| + old.Op != cfg.Binding.Op || old.Incarnation != cfg.Binding.Incarnation { |
| + return ErrState |
| + } |
| + // Only the caller's expected operation is cleared. A fresh successor |
| + // operation may exclude this box; that is also CC9 discharge evidence. |
| + if !q.Free() && q.Op.Kind != hostclaim.KindServing && |
| + slices.Contains(q.Op.Participants, old.Box) { |
| + return ErrInactive |
| + } |
| + return d.remove(old.Host) |
| + }) |
| +} |
diff --git a/internal/hostexec/controller_test.go b/internal/hostexec/controller_test.go| new file mode 100644 |
| index 000000000..c4cac8b18 |
| --- /dev/null |
| +++ b/internal/hostexec/controller_test.go |
| @@ -0,0 +1,441 @@ |
| +//go:build linux |
| + |
| +package hostexec |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostquarantine" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type alarm struct { |
| + at int64 |
| + ch chan time.Time |
| +} |
| +type testClock struct { |
| + mu sync.Mutex |
| + wall time.Time |
| + boot int64 |
| + alarms []alarm |
| +} |
| + |
| +func (k *testClock) Now() (time.Time, int64, error) { |
| + k.mu.Lock() |
| + defer k.mu.Unlock() |
| + return k.wall, k.boot, nil |
| +} |
| +func (k *testClock) After(d time.Duration) <-chan time.Time { |
| + k.mu.Lock() |
| + defer k.mu.Unlock() |
| + ch := make(chan time.Time, 1) |
| + if d <= 0 { |
| + ch <- k.wall |
| + } else { |
| + k.alarms = append(k.alarms, alarm{k.boot + int64(d), ch}) |
| + } |
| + return ch |
| +} |
| +func (k *testClock) advance(d time.Duration) { |
| + k.mu.Lock() |
| + defer k.mu.Unlock() |
| + k.boot += int64(d) |
| + k.wall = k.wall.Add(d) |
| + rest := k.alarms[:0] |
| + for _, a := range k.alarms { |
| + if a.at <= k.boot { |
| + a.ch <- k.wall |
| + } else { |
| + rest = append(rest, a) |
| + } |
| + } |
| + k.alarms = rest |
| +} |
| + |
| +type claims struct { |
| + mu sync.Mutex |
| + q hostclaim.Claim |
| + err error |
| +} |
| + |
| +func (r *claims) Read(context.Context, string) (hostclaim.Observed, error) { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + raw, _ := json.Marshal(r.q) |
| + var q hostclaim.Claim |
| + _ = json.Unmarshal(raw, &q) |
| + return hostclaim.Observed{Host: q.Host, Claim: &q, ETag: "claim-etag"}, r.err |
| +} |
| + |
| +type directory struct { |
| + state string |
| + err error |
| +} |
| + |
| +func (d directory) Lookup(context.Context, string) (hostquarantine.Actor, error) { |
| + return hostquarantine.Actor{Enrollment: "enroll", State: d.state}, d.err |
| +} |
| + |
| +type delayedStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + calls int |
| + onPut func(int) |
| + failAt int |
| + called chan int |
| +} |
| + |
| +func (s *delayedStore) Put(ctx context.Context, key string, b []byte, etag string) (string, error) { |
| + s.mu.Lock() |
| + s.calls++ |
| + n := s.calls |
| + s.mu.Unlock() |
| + if s.called != nil { |
| + s.called <- n |
| + } |
| + if s.onPut != nil { |
| + s.onPut(n) |
| + } |
| + if n == s.failAt { |
| + return "", errors.New("ambiguous network failure") |
| + } |
| + // Deliberately permit a late response even after cancellation. |
| + return s.Store.Put(context.Background(), key, b, etag) |
| +} |
| +func (s *delayedStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| +} |
| + |
| +func fixture(t *testing.T) (Config, *claims, *testClock, *delayedStore, *atomic.Int32) { |
| + t.Helper() |
| + dir := t.TempDir() |
| + for _, name := range []string{"hostguard", ".hostexec", "locks", "exec-locks"} { |
| + if err := os.Mkdir(filepath.Join(dir, name), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + host := "shop.example.com" |
| + inc, _ := hostclaim.Incarnation("bab", "jam", host, time.Time{}) |
| + q := hostclaim.Claim{V: 2, Host: host, Gen: 7, Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Incarnation: inc, Location: "instances/" + host, Stamp: 3}, Op: hostclaim.Op{Kind: hostclaim.KindDeleting, ID: "op", Phase: "stop", Exec: "box", Participants: []string{"box"}}} |
| + if err := q.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := &claims{q: q} |
| + clock := &testClock{wall: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), boot: int64(time.Hour)} |
| + store := &delayedStore{Store: release.NewDirStore(t.TempDir()), called: make(chan int, 20)} |
| + cleaned := &atomic.Int32{} |
| + cfg := Config{Binding: Binding{Box: "box", Enrollment: "enroll", Host: host, Incarnation: inc, Op: "op", BootID: "boot"}, Store: store, Guard: hostguard.Guard{DataDir: dir, LockDir: filepath.Join(dir, "locks"), Claims: r}, Directory: directory{state: "active"}, Clock: clock, LockDir: filepath.Join(dir, "exec-locks"), CloseAdmission: func() {}, CancelWork: func() {}, Cleanup: func(context.Context) error { cleaned.Add(1); return nil }} |
| + return cfg, r, clock, store, cleaned |
| +} |
| +func ready(t *testing.T, c *Controller) State { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + s, err := c.Ready(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return s |
| +} |
| +func closeController(t *testing.T, c *Controller) { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + if err := c.Close(ctx); err != nil { |
| + t.Error(err) |
| + } |
| +} |
| +func until(t *testing.T, fn func() bool) { |
| + t.Helper() |
| + deadline := time.Now().Add(2 * time.Second) |
| + for !fn() { |
| + if time.Now().After(deadline) { |
| + t.Fatal("condition did not become true") |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| +} |
| + |
| +func TestRenewalRequestStartAndIndependentExpiry(t *testing.T) { |
| + cfg, _, clock, store, cleaned := fixture(t) |
| + blocked := make(chan struct{}) |
| + store.onPut = func(n int) { |
| + if n == 2 { |
| + <-blocked |
| + } |
| + } |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeController(t, c) |
| + first := ready(t, c) |
| + key, _ := Key(first.Host, first.Box) |
| + b, _, err := store.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var wire map[string]any |
| + _ = json.Unmarshal(b, &wire) |
| + if len(wire) != 2 || wire["op"] != "op" { |
| + t.Fatalf("wire %s", b) |
| + } |
| + until(t, func() bool { clock.mu.Lock(); defer clock.mu.Unlock(); return len(clock.alarms) >= 2 }) |
| + clock.advance(Renewal) |
| + for { |
| + select { |
| + case n := <-store.called: |
| + if n == 2 { |
| + goto renewing |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("no renewal") |
| + } |
| + } |
| +renewing: |
| + clock.advance(65 * time.Second) |
| + select { |
| + case <-c.Lost(): |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("blocked PUT blocked watchdog") |
| + } |
| + if err := CheckLocal(cfg.Guard.DataDir, first.Binding, clock, cfg.LockDir); err == nil { |
| + t.Fatal("writer admitted at deadline") |
| + } |
| + close(blocked) |
| + <-c.done |
| + if _, err := c.Ready(t.Context()); err == nil { |
| + t.Fatal("late success revived controller") |
| + } |
| + until(t, func() bool { return cleaned.Load() >= 2 }) |
| +} |
| + |
| +func TestRenewalUsesStartAndWallRollbackCannotExtend(t *testing.T) { |
| + cfg, _, clock, store, _ := fixture(t) |
| + store.onPut = func(n int) { |
| + if n == 1 { |
| + clock.advance(9 * time.Second) |
| + } |
| + } |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeController(t, c) |
| + s := ready(t, c) |
| + if s.SafeUntil != int64(time.Hour+85*time.Second) { |
| + t.Fatalf("response-relative deadline %d", s.SafeUntil) |
| + } |
| + clock.mu.Lock() |
| + clock.wall = clock.wall.Add(-time.Hour) |
| + clock.mu.Unlock() |
| + clock.advance(76 * time.Second) |
| + if err := CheckLocal(cfg.Guard.DataDir, s.Binding, clock, cfg.LockDir); err == nil { |
| + t.Fatal("wall rollback extended deadline") |
| + } |
| +} |
| + |
| +func TestAmbiguousPUTLatchesAndConditionalReleasePreservesSuccessor(t *testing.T) { |
| + cfg, _, _, store, _ := fixture(t) |
| + store.failAt = 1 |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = c.Ready(t.Context()); err == nil { |
| + t.Fatal("ambiguous PUT admitted") |
| + } |
| + closeController(t, c) |
| + cfg, _, _, store, _ = fixture(t) |
| + c, err = Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s := ready(t, c) |
| + key, _ := Key(s.Host, s.Box) |
| + successor := []byte(`{"op":"successor","expires":"2030-01-01T00:00:00Z"}`) |
| + if _, err = store.Store.Put(t.Context(), key, successor, s.LeaseETag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = c.Close(t.Context()); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("release = %v", err) |
| + } |
| + got, _, err := store.Get(t.Context(), key) |
| + if err != nil || string(got) != string(successor) { |
| + t.Fatal("deleted successor") |
| + } |
| +} |
| + |
| +func TestControllerExclusionBootAndCC9(t *testing.T) { |
| + cfg, r, clock, _, _ := fixture(t) |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s := ready(t, c) |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + if _, err = Open(ctx, cfg); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("second controller = %v", err) |
| + } |
| + wrong := s.Binding |
| + wrong.BootID = "next-boot" |
| + if err = CheckLocal(cfg.Guard.DataDir, wrong, clock, cfg.LockDir); err == nil { |
| + t.Fatal("inherited prior boot") |
| + } |
| + closeController(t, c) |
| + if err = ClearStale(t.Context(), cfg); !errors.Is(err, ErrInactive) { |
| + t.Fatalf("cleared active op: %v", err) |
| + } |
| + r.mu.Lock() |
| + r.q.Op = hostclaim.Op{Kind: hostclaim.KindServing, Participants: []string{"box"}} |
| + r.mu.Unlock() |
| + if err = ClearStale(t.Context(), cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = os.Stat(filepath.Join(cfg.Guard.DataDir, ".hostexec", cfg.Binding.Host+".json")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("stale state remains", err) |
| + } |
| + // No lease traffic for retained serving participants, even when enabled. |
| + if _, err = Open(t.Context(), cfg); !errors.Is(err, ErrInactive) { |
| + t.Fatalf("serving controller = %v", err) |
| + } |
| +} |
| + |
| +func TestMalformedPendingMissingAndQuarantineRefuse(t *testing.T) { |
| + cfg, _, clock, _, _ := fixture(t) |
| + b := cfg.Binding |
| + b.ControllerID = "controller" |
| + if err := CheckLocal(cfg.Guard.DataDir, b, clock, cfg.LockDir); err == nil { |
| + t.Fatal("missing state admitted") |
| + } |
| + p := filepath.Join(cfg.Guard.DataDir, ".hostexec", b.Host+".json") |
| + if err := os.WriteFile(p, []byte(`{"v":1}`), 0660); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Open(t.Context(), cfg); err == nil { |
| + t.Fatal("malformed state replaced") |
| + } |
| + if err := os.Remove(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg.Directory = directory{state: "quarantined"} |
| + if _, err := Open(t.Context(), cfg); err == nil { |
| + t.Fatal("quarantined box admitted") |
| + } |
| +} |
| + |
| +func TestLocalPathsPinnedAndSymlinkRefused(t *testing.T) { |
| + cfg, _, _, _, _ := fixture(t) |
| + outside := filepath.Join(t.TempDir(), "outside") |
| + if err := os.WriteFile(outside, []byte("untouched"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + record := filepath.Join(cfg.Guard.DataDir, ".hostexec", cfg.Binding.Host+".json") |
| + if err := os.Symlink(outside, record); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Open(t.Context(), cfg); err == nil { |
| + t.Fatal("symlink admitted") |
| + } |
| + b, _ := os.ReadFile(outside) |
| + st, _ := os.Stat(outside) |
| + if string(b) != "untouched" || st.Mode().Perm() != 0600 { |
| + t.Fatal("changed symlink target") |
| + } |
| + if err := os.Remove(record); err != nil { |
| + t.Fatal(err) |
| + } |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + old := filepath.Join(cfg.Guard.DataDir, ".hostexec") |
| + if err = os.Rename(old, old+".pinned"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.Mkdir(old, 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := State{V: 1, Binding: cfg.Binding, State: "pending", Expires: time.Now().Add(TTL), SafeUntil: 99} |
| + s.ControllerID = "controller" |
| + if err = d.write(s); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = os.Stat(filepath.Join(old+".pinned", s.Host+".json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = os.Stat(filepath.Join(old, s.Host+".json")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("followed directory replacement") |
| + } |
| + d.sync = func(int) error { return errors.New("fsync failure") } |
| + if err = d.write(s); err == nil { |
| + t.Fatal("ignored persistence failure") |
| + } |
| + entries, _ := os.ReadDir(old + ".pinned") |
| + for _, e := range entries { |
| + if strings.HasPrefix(e.Name(), ".tmp-") { |
| + t.Fatal("leaked temp file") |
| + } |
| + } |
| +} |
| + |
| +func TestSuccessfulRenewalDurablyExtendsFromItsRequestStart(t *testing.T) { |
| + cfg, _, clock, store, _ := fixture(t) |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeController(t, c) |
| + first := ready(t, c) |
| + until(t, func() bool { |
| + clock.mu.Lock() |
| + defer clock.mu.Unlock() |
| + for _, a := range clock.alarms { |
| + if a.at == clock.boot+int64(Renewal) { |
| + return true |
| + } |
| + } |
| + return false |
| + }) |
| + clock.advance(Renewal) |
| + until(t, func() bool { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.state.SafeUntil == first.SafeUntil+int64(Renewal) |
| + }) |
| + renewed := ready(t, c) |
| + if renewed.LeaseETag == first.LeaseETag || !renewed.Expires.Equal(first.Expires.Add(Renewal)) { |
| + t.Fatalf("renewal did not advance coherent tuple: %+v", renewed) |
| + } |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + disk, err := d.read(renewed.Host) |
| + if err != nil || disk != renewed { |
| + t.Fatalf("renewal not durable: %+v %v", disk, err) |
| + } |
| + key, _ := Key(renewed.Host, renewed.Box) |
| + raw, etag, err := store.Get(t.Context(), key) |
| + if err != nil || etag != renewed.LeaseETag { |
| + t.Fatalf("renewed remote ownership: %q %v", etag, err) |
| + } |
| + var wire Record |
| + if err = json.Unmarshal(raw, &wire); err != nil || wire.Op != renewed.Op || !wire.Expires.Equal(renewed.Expires) { |
| + t.Fatalf("renewed wire: %+v %v", wire, err) |
| + } |
| +} |
diff --git a/internal/hostexec/local_other.go b/internal/hostexec/local_other.go| new file mode 100644 |
| index 000000000..1d2f2734b |
| --- /dev/null |
| +++ b/internal/hostexec/local_other.go |
| @@ -0,0 +1,17 @@ |
| +//go:build !unix |
| + |
| +package hostexec |
| + |
| +import "context" |
| + |
| +type localDir struct{} |
| + |
| +func openLocal(string) (*localDir, error) { return nil, ErrUnsupported } |
| +func (*localDir) close() {} |
| +func (*localDir) read(string) (State, error) { return State{}, ErrUnsupported } |
| +func (*localDir) write(State) error { return ErrUnsupported } |
| +func (*localDir) remove(string) error { return ErrUnsupported } |
| +func acquire(context.Context, string, string) (func(), error) { return nil, ErrUnsupported } |
| + |
| +func tryAcquire(string, string) (func(), error) { return nil, ErrUnsupported } |
| +func tryAcquireLive(string, string) (func(), error) { return nil, ErrUnsupported } |
diff --git a/internal/hostexec/local_unix.go b/internal/hostexec/local_unix.go| new file mode 100644 |
| index 000000000..932d86330 |
| --- /dev/null |
| +++ b/internal/hostexec/local_unix.go |
| @@ -0,0 +1,197 @@ |
| +//go:build unix |
| + |
| +package hostexec |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +type localDir struct { |
| + fd int |
| + sync func(int) error |
| +} |
| + |
| +func openDir(path string) (int, error) { |
| + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return -1, err |
| + } |
| + var st unix.Stat_t |
| + if err = unix.Fstat(fd, &st); err != nil { |
| + unix.Close(fd) |
| + return -1, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFDIR { |
| + unix.Close(fd) |
| + return -1, ErrState |
| + } |
| + return fd, nil |
| +} |
| +func openLocal(dataDir string) (*localDir, error) { |
| + fd, err := openDir(filepath.Join(dataDir, ".hostexec")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &localDir{fd: fd, sync: unix.Fsync}, nil |
| +} |
| +func (d *localDir) close() { _ = unix.Close(d.fd) } |
| +func (d *localDir) read(host string) (State, error) { |
| + var s State |
| + if _, err := Key(host, "check"); err != nil { |
| + return s, err |
| + } |
| + fd, err := unix.Openat(d.fd, host+".json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) |
| + if err != nil { |
| + return s, err |
| + } |
| + f := os.NewFile(uintptr(fd), host) |
| + defer f.Close() |
| + var st unix.Stat_t |
| + if err = unix.Fstat(fd, &st); err != nil { |
| + return s, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG { |
| + return s, ErrState |
| + } |
| + b, err := io.ReadAll(io.LimitReader(f, 65537)) |
| + if err != nil { |
| + return s, err |
| + } |
| + if len(b) > 65536 { |
| + return s, ErrState |
| + } |
| + if err = decode(b, &s); err != nil { |
| + return s, err |
| + } |
| + if err = s.validate(); err != nil { |
| + return s, err |
| + } |
| + if s.Host != host { |
| + return s, ErrState |
| + } |
| + return s, nil |
| +} |
| +func (d *localDir) write(s State) error { |
| + if err := s.validate(); err != nil { |
| + return err |
| + } |
| + if _, err := d.read(s.Host); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + var nonce [16]byte |
| + if _, err := rand.Read(nonce[:]); err != nil { |
| + return err |
| + } |
| + tmp := ".tmp-" + hex.EncodeToString(nonce[:]) |
| + fd, err := unix.Openat(d.fd, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0660) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Unlinkat(d.fd, tmp, 0) |
| + f := os.NewFile(uintptr(fd), tmp) |
| + defer f.Close() |
| + if err = unix.Fchmod(fd, 0660); err != nil { |
| + return err |
| + } |
| + b, err := json.Marshal(s) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err = f.Write(append(b, '\n')); err != nil { |
| + return err |
| + } |
| + if err = d.sync(fd); err != nil { |
| + return err |
| + } |
| + if err = f.Close(); err != nil { |
| + return err |
| + } |
| + if err = unix.Renameat(d.fd, tmp, d.fd, s.Host+".json"); err != nil { |
| + return err |
| + } |
| + return d.sync(d.fd) |
| +} |
| +func (d *localDir) remove(host string) error { |
| + if _, err := d.read(host); err != nil { |
| + return err |
| + } |
| + if err := unix.Unlinkat(d.fd, host+".json", 0); err != nil { |
| + return err |
| + } |
| + return d.sync(d.fd) |
| +} |
| +func tryAcquire(dir, host string) (func(), error) { |
| + return tryAcquireNamed(dir, host, ".lock") |
| +} |
| + |
| +func tryAcquireLive(dir, host string) (func(), error) { |
| + return tryAcquireNamed(dir, host, ".live") |
| +} |
| + |
| +func tryAcquireNamed(dir, host, suffix string) (func(), error) { |
| + if _, err := Key(host, "check"); err != nil { |
| + return nil, err |
| + } |
| + fd, err := openDir(dir) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer unix.Close(fd) |
| + flags := unix.O_RDWR | unix.O_CREAT | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK |
| + lock, err := unix.Openat(fd, host+suffix, flags|unix.O_EXCL, 0660) |
| + created := err == nil |
| + if errors.Is(err, unix.EEXIST) { |
| + lock, err = unix.Openat(fd, host+suffix, flags, 0660) |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + fail := func(e error) (func(), error) { unix.Close(lock); return nil, e } |
| + var st unix.Stat_t |
| + if err = unix.Fstat(lock, &st); err != nil { |
| + return fail(err) |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG { |
| + return fail(ErrState) |
| + } |
| + if created || st.Mode&07777 != 0660 { |
| + if err = unix.Fchmod(lock, 0660); err != nil { |
| + return fail(err) |
| + } |
| + } |
| + err = unix.Flock(lock, unix.LOCK_EX|unix.LOCK_NB) |
| + if err == nil { |
| + return func() { _ = unix.Flock(lock, unix.LOCK_UN); _ = unix.Close(lock) }, nil |
| + } |
| + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { |
| + return fail(ErrControllerBusy) |
| + } |
| + return fail(err) |
| +} |
| + |
| +func acquire(ctx context.Context, dir, host string) (func(), error) { |
| + for { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + unlock, err := tryAcquire(dir, host) |
| + if !errors.Is(err, ErrControllerBusy) { |
| + return unlock, err |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return nil, ctx.Err() |
| + case <-time.After(10 * time.Millisecond): |
| + } |
| + } |
| +} |
diff --git a/internal/hostexec/record.go b/internal/hostexec/record.go| new file mode 100644 |
| index 000000000..4a5927099 |
| --- /dev/null |
| +++ b/internal/hostexec/record.go |
| @@ -0,0 +1,94 @@ |
| +package hostexec |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| +) |
| + |
| +const TTL = 90 * time.Second |
| +const Renewal = 20 * time.Second |
| +const Margin = 5 * time.Second |
| + |
| +var ErrControllerBusy = errors.New("hostexec: controller already holds lifetime lock") |
| +var ErrLost = errors.New("hostexec: lease lost") |
| +var ErrInactive = errors.New("hostexec: no active participation") |
| +var ErrState = errors.New("hostexec: invalid local state") |
| +var ErrUnsupported = errors.New("hostexec: suspend-aware clock unavailable") |
| + |
| +// Record is the complete remote wire object. It grants no claim authority. |
| +type Record struct { |
| + Op string `json:"op"` |
| + Expires time.Time `json:"expires"` |
| +} |
| + |
| +type Binding struct { |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + Op string `json:"op"` |
| + BootID string `json:"boot_id"` |
| + ControllerID string `json:"controller_id"` |
| +} |
| + |
| +type State struct { |
| + V int `json:"v"` |
| + Binding |
| + LeaseETag string `json:"lease_etag"` |
| + Expires time.Time `json:"expires"` |
| + SafeUntil int64 `json:"safe_until_boottime_ns"` |
| + State string `json:"state"` |
| +} |
| + |
| +func validID(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00\r\n\t ") |
| +} |
| +func (b Binding) valid() bool { |
| + return controlname.ValidHostShape(b.Host) && validID(b.Box) && validID(b.Enrollment) && |
| + validID(b.Incarnation) && validID(b.Op) && validID(b.BootID) && validID(b.ControllerID) |
| +} |
| +func (s State) validate() error { |
| + if s.V != 1 || !s.Binding.valid() || s.SafeUntil <= 0 || s.Expires.IsZero() { |
| + return ErrState |
| + } |
| + switch s.State { |
| + case "pending", "lost": |
| + case "active": |
| + if s.LeaseETag == "" { |
| + return ErrState |
| + } |
| + default: |
| + return ErrState |
| + } |
| + return nil |
| +} |
| +func decode(b []byte, dst any) error { |
| + d := json.NewDecoder(bytes.NewReader(b)) |
| + d.DisallowUnknownFields() |
| + if err := d.Decode(dst); err != nil { |
| + return err |
| + } |
| + var extra any |
| + if err := d.Decode(&extra); err != io.EOF { |
| + return ErrState |
| + } |
| + return nil |
| +} |
| +func Key(host, box string) (string, error) { |
| + if !controlname.ValidHostShape(host) || !validID(box) { |
| + return "", ErrState |
| + } |
| + return "control/hosts/" + host + "/exec/" + box + ".json", nil |
| +} |
| + |
| +// BootNanos includes time spent suspended. Wall time is used only on the wire. |
| +type Clock interface { |
| + Now() (wall time.Time, bootNanos int64, err error) |
| + After(time.Duration) <-chan time.Time |
| +} |
diff --git a/internal/hostexec/review_test.go b/internal/hostexec/review_test.go| new file mode 100644 |
| index 000000000..cf1c78e15 |
| --- /dev/null |
| +++ b/internal/hostexec/review_test.go |
| @@ -0,0 +1,460 @@ |
| +//go:build linux |
| + |
| +package hostexec |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "golang.org/x/sys/unix" |
| + "os" |
| + "os/exec" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func TestLostControllerCannotPublishStaleActiveState(t *testing.T) { |
| + cfg, _, clock, _, _ := fixture(t) |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeController(t, c) |
| + active := ready(t, c) |
| + c.mu.Lock() |
| + c.loseLocked(ErrLost) |
| + c.mu.Unlock() |
| + <-c.cleanupDone |
| + if err = c.publish(active); !errors.Is(err, ErrLost) { |
| + t.Fatalf("stale publication = %v", err) |
| + } |
| + if err = CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); err == nil { |
| + t.Fatal("stale active state revived cross-process admission") |
| + } |
| +} |
| + |
| +func TestWaitingPublicationDoesNotBlockWatchdogOrCleanup(t *testing.T) { |
| + cfg, _, clock, _, cleaned := fixture(t) |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeController(t, c) |
| + active := ready(t, c) |
| + held, release := make(chan struct{}), make(chan struct{}) |
| + guardDone := make(chan error, 1) |
| + go func() { |
| + guardDone <- cfg.Guard.WithLocal(context.Background(), active.Host, func() error { |
| + close(held) |
| + <-release |
| + return nil |
| + }) |
| + }() |
| + <-held |
| + published := make(chan error, 1) |
| + go func() { published <- c.publish(active) }() |
| + clock.advance(TTL) |
| + // Ready samples the suspend-aware clock even if a watchdog wake races. |
| + if _, err = c.Ready(t.Context()); err == nil { |
| + t.Fatal("expired controller remained ready") |
| + } |
| + select { |
| + case <-c.Lost(): |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("host guard blocked watchdog") |
| + } |
| + until(t, func() bool { return cleaned.Load() >= 2 }) |
| + close(release) |
| + if err = <-guardDone; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = <-published; !errors.Is(err, ErrLost) { |
| + t.Fatalf("publication after expiration = %v", err) |
| + } |
| + <-c.cleanupDone |
| + if err = CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); err == nil { |
| + t.Fatal("expired publication admitted work") |
| + } |
| +} |
| + |
| +func TestOrphanActiveStateCannotAuthorizeAfterControllerExit(t *testing.T) { |
| + cfg, _, clock, _, _ := fixture(t) |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + active := ready(t, c) |
| + closeController(t, c) |
| + // Simulate an active rename surviving a crash before its fsync outcome was |
| + // known. The persisted deadline is still future, but no controller lives. |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + if err = d.write(active); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); !errors.Is(err, ErrLost) { |
| + t.Fatalf("orphan active state = %v", err) |
| + } |
| +} |
| + |
| +type blockedClaimRead struct { |
| + base *claims |
| + entered chan struct{} |
| + release chan struct{} |
| +} |
| + |
| +func (r blockedClaimRead) Read(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + close(r.entered) |
| + select { |
| + case <-ctx.Done(): |
| + return hostclaim.Observed{}, ctx.Err() |
| + case <-r.release: |
| + return r.base.Read(ctx, host) |
| + } |
| +} |
| + |
| +func TestCC9LockDoesNotReviveOrphanDuringClaimRead(t *testing.T) { |
| + cfg, r, clock, _, _ := fixture(t) |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + active := ready(t, c) |
| + closeController(t, c) |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + if err = d.write(active); err != nil { |
| + t.Fatal(err) |
| + } |
| + blocked := blockedClaimRead{base: r, entered: make(chan struct{}), release: make(chan struct{})} |
| + cfg.Guard.Claims = blocked |
| + done := make(chan error, 1) |
| + go func() { done <- ClearStale(t.Context(), cfg) }() |
| + <-blocked.entered |
| + checkErr := CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir) |
| + close(blocked.release) |
| + if checkErr == nil { |
| + t.Fatal("CC9 lifetime lock revived orphan active bytes") |
| + } |
| + if err = <-done; !errors.Is(err, ErrInactive) { |
| + t.Fatalf("CC9 with participation retained = %v", err) |
| + } |
| +} |
| + |
| +func TestCC9SuccessorExclusionClearsOnlyExpectedOperation(t *testing.T) { |
| + cfg, r, _, _, _ := fixture(t) |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ready(t, c) |
| + closeController(t, c) |
| + r.mu.Lock() |
| + r.q.Op.ID = "successor-op" |
| + r.q.Op.Exec = "other-box" |
| + r.q.Op.Participants = []string{"other-box"} |
| + r.mu.Unlock() |
| + if err = ClearStale(t.Context(), cfg); err != nil { |
| + t.Fatalf("successor excludes box: %v", err) |
| + } |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + if _, err = d.read(cfg.Binding.Host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("expected stale record removed: %v", err) |
| + } |
| + unrelated := State{V: 1, Binding: cfg.Binding, State: "lost", Expires: time.Now().Add(TTL), SafeUntil: 99} |
| + unrelated.ControllerID = "other-controller" |
| + unrelated.Op = "unrelated-op" |
| + if err = d.write(unrelated); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = ClearStale(t.Context(), cfg); !errors.Is(err, ErrState) { |
| + t.Fatalf("unrelated local fence: %v", err) |
| + } |
| + still, err := d.read(cfg.Binding.Host) |
| + if err != nil || still.Op != unrelated.Op { |
| + t.Fatalf("unrelated fence changed: %v, %v", still, err) |
| + } |
| +} |
| + |
| +func TestAmbiguousRenewalENOSPCBeforeRenameDeniesImmediately(t *testing.T) { |
| + cfg, _, clock, store, cleaned := fixture(t) |
| + store.failAt = 2 |
| + var noSpace atomic.Bool |
| + noSpace.Store(true) |
| + var failures atomic.Int32 |
| + cfg.writeLocal = func(d *localDir, s State) error { |
| + if s.State != "lost" || !noSpace.Load() { |
| + return d.write(s) |
| + } |
| + original := d.sync |
| + d.sync = func(int) error { return unix.ENOSPC } |
| + defer func() { d.sync = original }() |
| + err := d.write(s) // file fsync fails before rename; old active bytes remain |
| + if errors.Is(err, unix.ENOSPC) { |
| + failures.Add(1) |
| + } |
| + return err |
| + } |
| + c, err := Open(t.Context(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer func() { noSpace.Store(false); closeController(t, c) }() |
| + active := ready(t, c) |
| + until(t, func() bool { |
| + clock.mu.Lock() |
| + defer clock.mu.Unlock() |
| + for _, a := range clock.alarms { |
| + if a.at == clock.boot+int64(Renewal) { |
| + return true |
| + } |
| + } |
| + return false |
| + }) |
| + clock.advance(Renewal) |
| + select { |
| + case <-c.Lost(): |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("ambiguous renewal did not latch loss") |
| + } |
| + // Check from the loss signal onward, before waiting for any persistence. |
| + if err := CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); !errors.Is(err, ErrLost) { |
| + t.Fatalf("admitted immediately after ambiguity: %v", err) |
| + } |
| + until(t, func() bool { return failures.Load() >= 2 && cleaned.Load() >= 2 }) |
| + if _, err := c.Ready(t.Context()); err == nil { |
| + t.Fatal("in-memory admission survived loss") |
| + } |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + old, err := d.read(active.Host) |
| + if err != nil || old != active { |
| + t.Fatalf("test did not leave the old active file: %+v %v", old, err) |
| + } |
| + _, boot, _ := clock.Now() |
| + if boot >= active.SafeUntil { |
| + t.Fatal("test reached expiry instead of exercising immediate denial") |
| + } |
| + binding, _ := json.Marshal(active.Binding) |
| + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestCheckLocalRefusesLostLiveLock$") |
| + cmd.Env = append(os.Environ(), "HOSTEXEC_PROBE_BINDING="+string(binding), "HOSTEXEC_PROBE_DATA="+cfg.Guard.DataDir, "HOSTEXEC_PROBE_LOCKS="+cfg.LockDir) |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("independent process admitted old active state: %v\n%s", err, output) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + entered := false |
| + err = cfg.Guard.WithLocal(ctx, active.Host, func() error { entered = true; return nil }) |
| + if !errors.Is(err, context.DeadlineExceeded) || entered { |
| + t.Fatalf("guard released while denial write failed: %v entered=%v", err, entered) |
| + } |
| + if unlock, err := tryAcquire(cfg.LockDir, active.Host); !errors.Is(err, ErrControllerBusy) { |
| + if unlock != nil { |
| + unlock() |
| + } |
| + t.Fatalf("controller exclusion released: %v", err) |
| + } |
| + ctxClose, cancelClose := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancelClose() |
| + if err := c.Close(ctxClose); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("Close did not retain the unfinished controller: %v", err) |
| + } |
| + noSpace.Store(false) |
| + select { |
| + case <-c.cleanupDone: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("lost state was not retried after disk recovery") |
| + } |
| + lost, err := d.read(active.Host) |
| + if err != nil || lost.State != "lost" { |
| + t.Fatalf("denial not durable: %+v %v", lost, err) |
| + } |
| + if err := cfg.Guard.WithLocal(t.Context(), active.Host, func() error { |
| + if err := CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); !errors.Is(err, ErrLost) { |
| + t.Fatalf("disk recovery revived admission: %v", err) |
| + } |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestCheckLocalRefusesLostLiveLock(t *testing.T) { |
| + raw := os.Getenv("HOSTEXEC_PROBE_BINDING") |
| + if raw == "" { |
| + t.Skip("subprocess witness") |
| + } |
| + var binding Binding |
| + if err := json.Unmarshal([]byte(raw), &binding); err != nil { |
| + t.Fatal(err) |
| + } |
| + clock := &testClock{boot: int64(time.Hour + Renewal)} |
| + if err := CheckLocal(os.Getenv("HOSTEXEC_PROBE_DATA"), binding, clock, os.Getenv("HOSTEXEC_PROBE_LOCKS")); !errors.Is(err, ErrLost) { |
| + t.Fatalf("old active record authorized an independent process: %v", err) |
| + } |
| +} |
| + |
| +func TestOpenOrphanControllerPendingWriteFailureCleansBeforeReleasingExclusion(t *testing.T) { |
| + testOpenOrphanControllerPersistence(t, false) |
| +} |
| + |
| +func TestOpenBlockedPendingPersistenceStillCleansBeforeReleasingExclusion(t *testing.T) { |
| + testOpenOrphanControllerPersistence(t, true) |
| +} |
| + |
| +func testOpenOrphanControllerPersistence(t *testing.T, blocked bool) { |
| + cfg, _, clock, store, _ := fixture(t) |
| + wall, boot, _ := clock.Now() |
| + orphan := State{V: 1, Binding: cfg.Binding, State: "active", LeaseETag: "orphan-etag", Expires: wall.Add(TTL), SafeUntil: boot + int64(TTL-Margin)} |
| + orphan.ControllerID = "dead-controller" |
| + d, err := openLocal(cfg.Guard.DataDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + if err := d.write(orphan); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The controller's locks have vanished, but its active bytes and a host |
| + // process survived. Admission refusal alone cannot stop that process. |
| + process := exec.Command("sleep", "60") |
| + if err := process.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer process.Process.Kill() |
| + hostExited := make(chan struct{}) |
| + go func() { _ = process.Wait(); close(hostExited) }() |
| + allowJoin := make(chan struct{}) |
| + var joinOnce sync.Once |
| + defer joinOnce.Do(func() { close(allowJoin) }) |
| + cleaned := make(chan struct{}) |
| + cleanupResult := make(chan error, 1) |
| + cfg.Cleanup = func(ctx context.Context) error { |
| + cleanupResult <- ctx.Err() |
| + if err := process.Process.Kill(); err != nil { |
| + return err |
| + } |
| + <-hostExited |
| + close(cleaned) |
| + <-allowJoin |
| + return nil |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + writeEntered := make(chan struct{}) |
| + allowWrite := make(chan struct{}) |
| + var writeOnce sync.Once |
| + defer writeOnce.Do(func() { close(allowWrite) }) |
| + var noSpace atomic.Bool |
| + var failures atomic.Int32 |
| + noSpace.Store(true) |
| + defer noSpace.Store(false) |
| + cfg.writeLocal = func(d *localDir, s State) error { |
| + if noSpace.Load() { |
| + if s.State == "pending" { |
| + cancel() // cleanup must not inherit the failed opener's lifetime |
| + } |
| + original := d.sync |
| + d.sync = func(int) error { |
| + if blocked && s.State == "pending" { |
| + close(writeEntered) |
| + <-allowWrite // model an fsync that cannot return until released |
| + } |
| + return unix.ENOSPC |
| + } |
| + defer func() { d.sync = original }() |
| + err := d.write(s) |
| + if errors.Is(err, unix.ENOSPC) { |
| + failures.Add(1) |
| + } |
| + return err |
| + } |
| + return d.write(s) |
| + } |
| + done := make(chan error, 1) |
| + go func() { _, err := Open(ctx, cfg); done <- err }() |
| + select { |
| + case <-cleaned: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("failed pending write skipped orphan process cleanup") |
| + } |
| + if err := <-cleanupResult; err != nil { |
| + t.Fatalf("cleanup inherited caller cancellation: %v", err) |
| + } |
| + |
| + if old, err := d.read(orphan.Host); err != nil || old != orphan { |
| + t.Fatalf("failed persistence did not retain orphan active bytes: %+v %v", old, err) |
| + } |
| + assertExcluded := func() { |
| + t.Helper() |
| + if unlock, err := tryAcquire(cfg.LockDir, orphan.Host); !errors.Is(err, ErrControllerBusy) { |
| + if unlock != nil { |
| + unlock() |
| + } |
| + t.Fatalf("exclusion released before cleanup joined: %v", err) |
| + } |
| + select { |
| + case err := <-done: |
| + t.Fatalf("Open returned before cleanup joined: %v", err) |
| + default: |
| + } |
| + } |
| + if blocked { |
| + select { |
| + case <-writeEntered: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("pending fsync was not reached") |
| + } |
| + if failures.Load() != 0 { |
| + t.Fatal("pending fsync returned before cleanup was observed") |
| + } |
| + assertExcluded() |
| + writeOnce.Do(func() { close(allowWrite) }) |
| + } |
| + until(t, func() bool { return failures.Load() >= 1 }) |
| + assertExcluded() |
| + if err := CheckLocal(cfg.Guard.DataDir, orphan.Binding, clock, cfg.LockDir); !errors.Is(err, ErrLost) { |
| + t.Fatalf("orphan bytes admitted work: %v", err) |
| + } |
| + // Persistence is still unavailable when cleanup joins. No live lock was |
| + // acquired, so orphan bytes cannot authorize work after exclusion ends. |
| + joinOnce.Do(func() { close(allowJoin) }) |
| + select { |
| + case err := <-done: |
| + if !errors.Is(err, unix.ENOSPC) { |
| + t.Fatalf("Open lost pending-write failure: %v", err) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("Open did not join cleanup while persistence remained unavailable") |
| + } |
| + unlock, err := tryAcquire(cfg.LockDir, orphan.Host) |
| + if err != nil { |
| + t.Fatalf("exclusion retained after cleanup joined: %v", err) |
| + } |
| + unlock() |
| + if err := CheckLocal(cfg.Guard.DataDir, orphan.Binding, clock, cfg.LockDir); !errors.Is(err, ErrLost) { |
| + t.Fatalf("released exclusion revived orphan bytes: %v", err) |
| + } |
| + store.mu.Lock() |
| + defer store.mu.Unlock() |
| + if store.calls != 0 { |
| + t.Fatalf("failed startup attempted %d lease writes", store.calls) |
| + } |
| +} |
diff --git a/internal/hostguard/local.go b/internal/hostguard/local.go| new file mode 100644 |
| index 000000000..2b4553096 |
| --- /dev/null |
| +++ b/internal/hostguard/local.go |
| @@ -0,0 +1,13 @@ |
| +package hostguard |
| + |
| +import "context" |
| + |
| +// WithLocal holds the existing host lock for local bookkeeping or cleanup. |
| +// It grants no claim, installation, spawn or write authority. Ordinary host |
| +// work must still use With. The callback must not wait on a network request. |
| +func (g Guard) WithLocal(ctx context.Context, host string, fn func() error) error { |
| + if fn == nil { |
| + return ErrInvalidInput |
| + } |
| + return g.locked(ctx, host, func(_ *recordDir) error { return fn() }) |
| +} |
diff --git a/internal/hostguard/local_test.go b/internal/hostguard/local_test.go| new file mode 100644 |
| index 000000000..a9b5f5149 |
| --- /dev/null |
| +++ b/internal/hostguard/local_test.go |
| @@ -0,0 +1,45 @@ |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestWithLocalSerializesWithoutClaimAuthority(t *testing.T) { |
| + dir := t.TempDir() |
| + locks := t.TempDir() |
| + if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + g := Guard{DataDir: dir, LockDir: locks} |
| + entered := make(chan struct{}) |
| + release := make(chan struct{}) |
| + done := make(chan error, 1) |
| + go func() { |
| + done <- g.WithLocal(t.Context(), "shop.example.com", func() error { close(entered); <-release; return nil }) |
| + }() |
| + <-entered |
| + ctx, cancel := context.WithTimeout(t.Context(), 25*time.Millisecond) |
| + defer cancel() |
| + called := false |
| + err := g.WithLocal(ctx, "shop.example.com", func() error { called = true; return nil }) |
| + close(release) |
| + if first := <-done; first != nil { |
| + t.Fatal(first) |
| + } |
| + if !errors.Is(err, context.DeadlineExceeded) || called { |
| + t.Fatalf("concurrent local write: %v %v", err, called) |
| + } |
| + // No Claims reader is configured: WithLocal may clean up, but With cannot |
| + // turn the same lock into installation authority. |
| + if err := g.With(t.Context(), "shop.example.com", Captured{}, func(_ *hostclaim.Claim) error { return nil }, func() error { return nil }); !errors.Is(err, ErrNoClaims) { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/lease/execution_store.go b/internal/lease/execution_store.go| new file mode 100644 |
| index 000000000..0fe927281 |
| --- /dev/null |
| +++ b/internal/lease/execution_store.go |
| @@ -0,0 +1,20 @@ |
| +package lease |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ExecutionStore reuses the replica leaser's S3 configuration and the release |
| +// store's conditional primitives. The execution controller owns its wire schema. |
| +func ExecutionStore(ctx context.Context, bucket, region string) (release.Store, error) { |
| + if bucket == "" { |
| + return nil, errors.New("lease: control bucket required") |
| + } |
| + client, _, err := newLeaserClient(ctx, region) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &release.S3Store{Client: client, Bucket: bucket}, nil |
| +} |
diff --git a/internal/lease/execution_store_test.go b/internal/lease/execution_store_test.go| new file mode 100644 |
| index 000000000..19d68e72f |
| --- /dev/null |
| +++ b/internal/lease/execution_store_test.go |
| @@ -0,0 +1,33 @@ |
| +package lease |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/release" |
| + "testing" |
| +) |
| + |
| +func TestExecutionStoreUsesExistingConditionalTransport(t *testing.T) { |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("AWS_CONFIG_FILE", t.TempDir()+"/absent") |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", t.TempDir()+"/absent") |
| + t.Setenv("CARLOS_S3_ENDPOINT", "https://objects.invalid") |
| + if _, err := ExecutionStore(t.Context(), "", "eu-west-1"); err == nil { |
| + t.Fatal("empty bucket accepted") |
| + } |
| + store, err := ExecutionStore(t.Context(), "control", "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s, ok := store.(*release.S3Store) |
| + if !ok || s.Bucket != "control" { |
| + t.Fatal("wrong store") |
| + } |
| + opts := s.Client.Options() |
| + if opts.Region != "eu-west-1" || opts.BaseEndpoint == nil || *opts.BaseEndpoint != "https://objects.invalid" || !opts.UsePathStyle { |
| + t.Fatalf("lost leaser configuration: %+v", opts) |
| + } |
| + if _, ok := store.(release.ConditionalDeleter); !ok { |
| + t.Fatal("no conditional release") |
| + } |
| +} |