package host import ( "context" "crypto/ed25519" "crypto/sha256" "encoding/hex" "errors" "io" "log" "math/rand/v2" "os" "path/filepath" "runtime" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/version" ) // systemLeg is the sandboxed half of the platform self-update (spec §4): // notice the channel pointer, fetch and verify the artifact into /data, ask // root. Two entry points so the spread has something to run it: observe on // a serial move (and hourly), tick every 2 s with no store read. type systemLeg struct { store release.Store pub ed25519.PublicKey channel string platform string build string dataDir string optDir string now func() time.Time jitter func(time.Duration) time.Duration // fetchTo is release.FetchToFile bound to this leg's store and limit, // behind a seam so a test can count downloads and make one fail. fetchTo func(ctx context.Context, key, dst, wantSHA256 string) error logf func(format string, args ...any) pending *systemPending // backoff is ONE retry ladder for everything that can go wrong after the // deadline — an unreachable store on the fetch, an unwritable /data on // the request, and root's own "deferred" — because they are all the same // failure from this loop's point of view: the work could not be done, and // hammering it every 2 s is what must not happen. Base 2 s doubling to // 5 min, cleared when the pointer moves or root answers. backoff time.Duration backoffUntil time.Time lastTouch time.Time lastObserve time.Time // loggedNoArtifact is the version whose "not for this box" line has // already been said. Without it the hourly re-observe would repeat it // forever for a fleet that mixes architectures. loggedNoArtifact string observeHook func() // test seam } // systemPending is the one promote this box is working on: what observe // decided, and when tick may act on it. type systemPending struct { version string promotedAt time.Time noop bool artifact release.SystemArtifact deadline time.Time requested bool } const systemObserveEvery = time.Hour func newSystemLeg(store release.Store, pub ed25519.PublicKey, getenv func(string) string, dataDir, optDir string) *systemLeg { channel, invalid := systemChannel(getenv) l := &systemLeg{ store: store, pub: pub, channel: channel, platform: runtime.GOOS + "-" + runtime.GOARCH, build: version.Build, dataDir: dataDir, optDir: optDir, now: time.Now, jitter: func(spread time.Duration) time.Duration { return rand.N(spread + 1) }, fetchTo: func(ctx context.Context, key, dst, wantSHA256 string) error { return release.FetchToFile(ctx, store, key, dst, systemArtifactLimit, wantSHA256) }, logf: log.Printf, } // Once, here at arming, rather than on every tick that reads the value. if invalid != "" { l.printf("system: %s=%q is not a channel; this box follows %s", systemChannelEnv, invalid, l.channel) } return l } func (l *systemLeg) printf(format string, args ...any) { if l.logf != nil { l.logf(format, args...) return } log.Printf(format, args...) } // observe reads the pointer and decides. It never blocks on a download: // that happens in tick, after the spread. func (l *systemLeg) observe(ctx context.Context) { if l.observeHook != nil { l.observeHook() return } now := l.now() l.lastObserve = now p, _, err := release.GetSystemPointer(ctx, l.store, l.pub, release.SystemComponentRouter, l.channel) if err != nil { if !errors.Is(err, os.ErrNotExist) { l.printf("agent: system pointer %s: %v", l.channel, err) } return } seen, _ := readSystemSeen(l.optDir) if wm, ok := seen[l.channel]; ok && p.PromotedAt.Before(wm) { return // root would refuse it as a replay } if res, _ := readSystemResult(l.optDir); res.matches(l.channel, p.Current.Version, p.PromotedAt) && res.terminal() { l.forget(p.Current.Version) return // refuse to repeat a failure } noop := p.Current.Version == l.build if noop { if wm, ok := seen[l.channel]; ok && !p.PromotedAt.After(wm) { return // nothing to do, and root has seen this promote } } art, ok := p.Current.Artifacts[l.platform] if !ok && !noop { if l.loggedNoArtifact != p.Current.Version { l.printf("agent: system pointer %s names %s with no artifact for %s — not for this box", l.channel, p.Current.Version, l.platform) l.loggedNoArtifact = p.Current.Version } return } if l.pending != nil && l.pending.version == p.Current.Version && l.pending.promotedAt.Equal(p.PromotedAt) { return // already pending; keep its deadline } // A different pointer is new work, not a continuation of the old one: // its retry ladder starts at the base (same rule as noteRestartFailed's // "a DIFFERENT version starts the hold fresh"). l.backoff, l.backoffUntil = 0, time.Time{} l.pending = &systemPending{version: p.Current.Version, promotedAt: p.PromotedAt, noop: noop, artifact: art, deadline: now.Add(l.jitter(p.SpreadDuration()))} if noop { // Nothing to install and nothing to spread: root only needs to // record the watermark, so it may as well hear about it now. l.pending.deadline = now } l.pruneCache(p.Current.Version) } // tick fires a pending request whose deadline has passed and re-touches an // unanswered one every minute. It reads no store except for the one fetch. func (l *systemLeg) tick(ctx context.Context, now time.Time) { switch { case l.lastObserve.IsZero(): // First tick after arming: start the hourly clock rather than // observe. The serial poll above is what makes a fresh promote // early; this floor is only the backstop for a promote that // landed while nobody bumped the serial. l.lastObserve = now case now.Sub(l.lastObserve) >= systemObserveEvery: l.observe(ctx) } p := l.pending if p == nil { return } if res, _ := readSystemResult(l.optDir); res.matches(l.channel, p.version, p.promotedAt) { switch { case res.terminal(): l.forget(p.version) return case res.Outcome == systemOutcomeNoted: l.pending = nil return case res.Outcome == systemOutcomeDeferred && res.At.After(p.deadline): // root could not reach the store; try again with backoff p.deadline = now.Add(l.nextBackoff()) p.requested = false } } if now.Before(p.deadline) || now.Before(l.backoffUntil) { return } if !p.requested { if !p.noop { if err := l.fetch(ctx, p); err != nil { l.printf("agent: system update %s: %v", p.version, err) os.Remove(systemCachePath(l.dataDir, p.version)) l.holdOff(now) return } } if err := l.writeRequest(p, now); err != nil { // Same ladder as the fetch: without it the next tick would // re-run the whole download for a /data this process cannot // write, every 2 seconds. l.printf("agent: system update %s: request: %v", p.version, err) l.holdOff(now) return } p.requested = true } if now.Sub(l.lastTouch) >= systemRequestEvery { if err := os.WriteFile(filepath.Join(l.dataDir, systemRequestMarker), []byte(now.UTC().Format(time.RFC3339Nano)+"\n"), 0o644); err != nil { l.printf("agent: system update %s: cannot ask for a root apply: %v", p.version, err) return } l.lastTouch = now } } // nextBackoff advances the retry ladder one rung: base 2 s, doubling, capped // at 5 min — the clamp shape fastDeps.noteUnstaged uses, so a step can never // overshoot the cap. func (l *systemLeg) nextBackoff() time.Duration { if l.backoff == 0 { l.backoff = fastBackoffBase } else if l.backoff *= 2; l.backoff > fastBackoffCap { l.backoff = fastBackoffCap } return l.backoff } // holdOff pushes the next attempt out by the ladder's next rung. What it // bounds is a 96 MB download: a permanently unfetchable artifact must cost // this box four attempts an hour, not one every two seconds. func (l *systemLeg) holdOff(now time.Time) { l.backoffUntil = now.Add(l.nextBackoff()) } func (l *systemLeg) fetch(ctx context.Context, p *systemPending) error { dst := systemCachePath(l.dataDir, p.version) if systemCacheHolds(dst, p.artifact.SHA256) { return nil // already downloaded and verified — a deferred retry or a // re-promote of the same version must not pull it again } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } return l.fetchTo(ctx, p.artifact.Key, dst, p.artifact.SHA256) } // systemCacheHolds reports whether the cache file at path IS the artifact // the pointer signed. It hashes the file: presence proves nothing, and a // truncated or swapped file is exactly what the signed sha is there for. func systemCacheHolds(path, wantSHA256 string) bool { if wantSHA256 == "" { return false } f, err := os.Open(path) if err != nil { return false } defer f.Close() h := sha256.New() if _, err := io.Copy(h, io.LimitReader(f, systemArtifactLimit+1)); err != nil { return false } return hex.EncodeToString(h.Sum(nil)) == wantSHA256 } func (l *systemLeg) writeRequest(p *systemPending, now time.Time) error { if err := os.MkdirAll(systemDataDir(l.dataDir), 0o755); err != nil { return err } return writeJSONDurable(systemRequestPath(l.dataDir), systemRequest{Version: p.version, PromotedAt: p.promotedAt, NoOp: p.noop, SeenAt: now.UTC()}, 0o644) } // pruneCache drops every cached version except keep. A promote that moves // v2 → v3 before root answered would otherwise leave v2's binary under /data // for good — nobody else deletes there. Only names validSystemVersion // accepts are removed, so requested.json and anything else under the // directory cannot be caught by it. func (l *systemLeg) pruneCache(keep string) { dir := systemDataDir(l.dataDir) ents, err := os.ReadDir(dir) if err != nil { return } for _, e := range ents { if !e.IsDir() || e.Name() == keep || !validSystemVersion(e.Name()) { continue } if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { l.printf("agent: system update: cannot drop the cached %s: %v", e.Name(), err) } } } // forget: root has answered with a terminal outcome. Drop the pending item // and the cache — root never deletes under /data, the agent does. Only the // pending for THIS version is dropped: a newer pointer may already be armed. func (l *systemLeg) forget(version string) { if l.pending != nil && l.pending.version == version { l.pending = nil l.backoff, l.backoffUntil = 0, time.Time{} } if validSystemVersion(version) { os.RemoveAll(filepath.Dir(systemCachePath(l.dataDir, version))) } }