// The agent's half of D3 (spec 2026-10-06-console-release-defences-design.md // §4.3, §4.5). // // agentProofGate is the one release.ProofGate this process passes to every // pass it runs (adoptLoop, fastConvergeLoop, the cold-wake hook), so the miss // cache, the waiting list fastMembers reads and the status line all agree. // // mintAsker runs on the proving box's fast loop. Minting needs the root-only // key, and root passes run hourly or on request, so when a listed app's live // build has run for the policy's after with no proof in the bucket, the asker // requests a root pass through the stage-request relay. It asks again every // mintAskEvery until it sees the proof (codex 5): the relay can miss a request // written while carlos-stage.service is running, and a mint write can fail. // It also leaves a pending note the first time it sees a build live, which is // how `carlos proof get` can say how long the build has had. // // It cannot check everything a mint needs (it cannot read another unit's // /proc//exe), and need not: the root pass checks every condition again. // When systemctl show is denied here, it asks anyway and root decides. package host import ( "context" "errors" "log" "os" "path/filepath" "sync" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) var ( proofGateOnce sync.Once proofGateInst *release.ProofGate ) // agentProofGate reads root-observed healthy history. Its not-proven // diagnostics stay in dataDir(), beside the agent pass's refusal records. func agentProofGate() *release.ProofGate { proofGateOnce.Do(func() { proofGateInst = release.NewRootProofGate(release.ProofPolicyPath, dataDir()) }) return proofGateInst } const mintAskEvery = 2 * time.Minute type mintAsker struct { reg *registry.Registry store release.Store sys unitSystem optDir string policyPath string request func(host, ver string, err error) // mintRequester in production now func() time.Time last map[string]time.Time // host -> when it was last looked at proven map[provenKey]bool // proofs seen in the bucket pending map[string]string // host -> sha256 whose pending note is written shaByPath map[string]hashedFile } // hashedFile is a staged file's sha256 and the identity of the file it was // taken from. A version directory can be pruned and its version name reused // for other bytes at the same path, so a cached hash is used only while the // file is still the same one: same size, mtime and, where the platform says, // device and inode (final review H5). type hashedFile struct { size int64 mtime int64 dev, ino uint64 sha string } func identify(fi os.FileInfo) hashedFile { dev, ino, _ := fileInode(fi) return hashedFile{size: fi.Size(), mtime: fi.ModTime().UnixNano(), dev: dev, ino: ino} } func (h hashedFile) sameFile(o hashedFile) bool { return h.size == o.size && h.mtime == o.mtime && h.dev == o.dev && h.ino == o.ino } // provenKey is one proof object: a sha256 the bucket holds a proof for, // under one account, app and prover. A host is not part of it, and neither // a change of prover nor of account can reuse another's answer. type provenKey struct{ acct, app, sha, prover string } // stagedSHA is target's sha256, hashed again whenever the file is not the // one the cached hash came from. func (a *mintAsker) stagedSHA(target string) (string, error) { fi, err := os.Stat(target) if err != nil { return "", err } id := identify(fi) if h, ok := a.shaByPath[target]; ok && h.sameFile(id) { return h.sha, nil } sha, err := release.FileSHA256(target) if err != nil { return "", err } // The file can change between the stat and the read; a hash taken across // that change is used once and not cached. if fi2, err := os.Stat(target); err == nil && identify(fi2).sameFile(id) { id.sha = sha a.shaByPath[target] = id } return sha, nil } func (a *mintAsker) tick(ctx context.Context) { // A policy file that does not parse is closed (Task 2's carry): root // mints nothing until it parses (proofroot.go says so on every pass, and // the gate once a minute), so there is nothing to ask root for. An // absent file, or one with no prove block, is a box that proves nothing. pol, err := release.LoadProofPolicy(a.policyPath) if err != nil || pol == nil || pol.Prove == nil { return } routes, err := a.reg.List() if err != nil { return } if a.last == nil { a.last, a.proven, a.pending, a.shaByPath = map[string]time.Time{}, map[provenKey]bool{}, map[string]string{}, map[string]hashedFile{} } now := a.now() after := pol.ProveAfter() prover := pol.Prove.Prover for _, rt := range routes { if !release.Adoptable(rt) || rt.Unit == "" || !pol.Proves(rt.App) { continue } if last, ok := a.last[rt.Host]; ok && now.Sub(last) < mintAskEvery { continue } a.last[rt.Host] = now target, err := filepath.EvalSymlinks(release.LiveLink(a.optDir, rt.Host)) if err != nil { continue } sha, err := a.stagedSHA(target) if err != nil { continue } acct := release.RouteAccount(rt) pk := provenKey{acct, rt.App, sha, prover} if a.proven[pk] { continue } ver, _ := release.LiveVersion(a.optDir, rt.Host) if p, err := a.sys.Show(rt.Unit); err == nil { if p.ActiveState != "active" { continue } if !p.ActiveEnterTimestamp.IsZero() { if a.pending[rt.Host] != sha { if err := release.PutProofPending(ctx, a.store, acct, release.ProofPending{App: rt.App, Platform: release.AdoptPlatform(), SHA256: sha, Version: ver, Prover: prover, LiveSince: p.ActiveEnterTimestamp.UTC(), After: pol.Prove.After}); err != nil { log.Printf("agent: %s: writing the proof pending note: %v", rt.Host, err) } else { a.pending[rt.Host] = sha } } if now.Sub(p.ActiveEnterTimestamp) < after { continue } } } _, err = release.GetProof(ctx, a.store, acct, rt.App, sha, prover) switch { case err == nil: a.proven[pk] = true continue case !errors.Is(err, os.ErrNotExist): log.Printf("agent: %s: reading the bucket for %s's proof: %v", rt.Host, ver, err) continue } log.Printf("agent: %s has run %s for %s with no proof in the bucket; asking for a root pass to mint one", rt.Host, ver, after) a.request(rt.Host, ver, nil) } }