carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The agent's half of D3 (spec 2026-10-06-console-release-defences-design.md
2// §4.3, §4.5).
3//
4// agentProofGate is the one release.ProofGate this process passes to every
5// pass it runs (adoptLoop, fastConvergeLoop, the cold-wake hook), so the miss
6// cache, the waiting list fastMembers reads and the status line all agree.
7//
8// mintAsker runs on the proving box's fast loop. Minting needs the root-only
9// key, and root passes run hourly or on request, so when a listed app's live
10// build has run for the policy's after with no proof in the bucket, the asker
11// requests a root pass through the stage-request relay. It asks again every
12// mintAskEvery until it sees the proof (codex 5): the relay can miss a request
13// written while carlos-stage.service is running, and a mint write can fail.
14// It also leaves a pending note the first time it sees a build live, which is
15// how `carlos proof get` can say how long the build has had.
16//
17// It cannot check everything a mint needs (it cannot read another unit's
18// /proc/<pid>/exe), and need not: the root pass checks every condition again.
19// When systemctl show is denied here, it asks anyway and root decides.
20package host
21
22import (
23 "context"
24 "errors"
25 "log"
26 "os"
27 "path/filepath"
28 "sync"
29 "time"
30
31 "github.com/carlosframework/platform/internal/registry"
32 "github.com/carlosframework/platform/internal/release"
33)
34
35var (
36 proofGateOnce sync.Once
37 proofGateInst *release.ProofGate
38)
39
40// agentProofGate reads root-observed healthy history. Its not-proven
41// diagnostics stay in dataDir(), beside the agent pass's refusal records.
42func agentProofGate() *release.ProofGate {
43 proofGateOnce.Do(func() { proofGateInst = release.NewRootProofGate(release.ProofPolicyPath, dataDir()) })
44 return proofGateInst
45}
46
47const mintAskEvery = 2 * time.Minute
48
49type mintAsker struct {
50 reg *registry.Registry
51 store release.Store
52 sys unitSystem
53 optDir string
54 policyPath string
55 request func(host, ver string, err error) // mintRequester in production
56 now func() time.Time
57
58 last map[string]time.Time // host -> when it was last looked at
59 proven map[provenKey]bool // proofs seen in the bucket
60 pending map[string]string // host -> sha256 whose pending note is written
61 shaByPath map[string]hashedFile
62}
63
64// hashedFile is a staged file's sha256 and the identity of the file it was
65// taken from. A version directory can be pruned and its version name reused
66// for other bytes at the same path, so a cached hash is used only while the
67// file is still the same one: same size, mtime and, where the platform says,
68// device and inode (final review H5).
69type hashedFile struct {
70 size int64
71 mtime int64
72 dev, ino uint64
73 sha string
74}
75
76func identify(fi os.FileInfo) hashedFile {
77 dev, ino, _ := fileInode(fi)
78 return hashedFile{size: fi.Size(), mtime: fi.ModTime().UnixNano(), dev: dev, ino: ino}
79}
80
81func (h hashedFile) sameFile(o hashedFile) bool {
82 return h.size == o.size && h.mtime == o.mtime && h.dev == o.dev && h.ino == o.ino
83}
84
85// provenKey is one proof object: a sha256 the bucket holds a proof for,
86// under one account, app and prover. A host is not part of it, and neither
87// a change of prover nor of account can reuse another's answer.
88type provenKey struct{ acct, app, sha, prover string }
89
90// stagedSHA is target's sha256, hashed again whenever the file is not the
91// one the cached hash came from.
92func (a *mintAsker) stagedSHA(target string) (string, error) {
93 fi, err := os.Stat(target)
94 if err != nil {
95 return "", err
96 }
97 id := identify(fi)
98 if h, ok := a.shaByPath[target]; ok && h.sameFile(id) {
99 return h.sha, nil
100 }
101 sha, err := release.FileSHA256(target)
102 if err != nil {
103 return "", err
104 }
105 // The file can change between the stat and the read; a hash taken across
106 // that change is used once and not cached.
107 if fi2, err := os.Stat(target); err == nil && identify(fi2).sameFile(id) {
108 id.sha = sha
109 a.shaByPath[target] = id
110 }
111 return sha, nil
112}
113
114func (a *mintAsker) tick(ctx context.Context) {
115 // A policy file that does not parse is closed (Task 2's carry): root
116 // mints nothing until it parses (proofroot.go says so on every pass, and
117 // the gate once a minute), so there is nothing to ask root for. An
118 // absent file, or one with no prove block, is a box that proves nothing.
119 pol, err := release.LoadProofPolicy(a.policyPath)
120 if err != nil || pol == nil || pol.Prove == nil {
121 return
122 }
123 routes, err := a.reg.List()
124 if err != nil {
125 return
126 }
127 if a.last == nil {
128 a.last, a.proven, a.pending, a.shaByPath = map[string]time.Time{}, map[provenKey]bool{}, map[string]string{}, map[string]hashedFile{}
129 }
130 now := a.now()
131 after := pol.ProveAfter()
132 prover := pol.Prove.Prover
133 for _, rt := range routes {
134 if !release.Adoptable(rt) || rt.Unit == "" || !pol.Proves(rt.App) {
135 continue
136 }
137 if last, ok := a.last[rt.Host]; ok && now.Sub(last) < mintAskEvery {
138 continue
139 }
140 a.last[rt.Host] = now
141 target, err := filepath.EvalSymlinks(release.LiveLink(a.optDir, rt.Host))
142 if err != nil {
143 continue
144 }
145 sha, err := a.stagedSHA(target)
146 if err != nil {
147 continue
148 }
149 acct := release.RouteAccount(rt)
150 pk := provenKey{acct, rt.App, sha, prover}
151 if a.proven[pk] {
152 continue
153 }
154 ver, _ := release.LiveVersion(a.optDir, rt.Host)
155 if p, err := a.sys.Show(rt.Unit); err == nil {
156 if p.ActiveState != "active" {
157 continue
158 }
159 if !p.ActiveEnterTimestamp.IsZero() {
160 if a.pending[rt.Host] != sha {
161 if err := release.PutProofPending(ctx, a.store, acct, release.ProofPending{App: rt.App,
162 Platform: release.AdoptPlatform(), SHA256: sha, Version: ver, Prover: prover,
163 LiveSince: p.ActiveEnterTimestamp.UTC(), After: pol.Prove.After}); err != nil {
164 log.Printf("agent: %s: writing the proof pending note: %v", rt.Host, err)
165 } else {
166 a.pending[rt.Host] = sha
167 }
168 }
169 if now.Sub(p.ActiveEnterTimestamp) < after {
170 continue
171 }
172 }
173 }
174 _, err = release.GetProof(ctx, a.store, acct, rt.App, sha, prover)
175 switch {
176 case err == nil:
177 a.proven[pk] = true
178 continue
179 case !errors.Is(err, os.ErrNotExist):
180 log.Printf("agent: %s: reading the bucket for %s's proof: %v", rt.Host, ver, err)
181 continue
182 }
183 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)
184 a.request(rt.Host, ver, nil)
185 }
186}
187