carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The box-side half of the metrics sampler: every function here reads local
2// kernel/OS state (cgroup v2 and /proc files) and MUST fail toward nil, never
3// a guessed zero. This file is thin OS glue — os.ReadFile and os.ReadDir
4// wrapping the pure parsers in internal/metrics — and the readers are
5// deliberately untested by the same convention as probes.go readers;
6// internal/metrics tests exercise the parsers with fixtures instead. The
7// exceptions are listCarlosUnits, listStampedUnits and listExecCgroups: they are directory
8// WALKs, not single reads, and which directories they walk is a decision that
9// has already been wrong once (see listCarlosUnits' doc comment), so they take
10// their roots as arguments and are pinned by metrics_probes_test.go against
11// temp-dir fixtures.
12package host
13
14import (
15 "os"
16 "path/filepath"
17 "strings"
18
19 "github.com/carlosframework/platform/internal/cgroup"
20 "github.com/carlosframework/platform/internal/metrics"
21)
22
23// readProcStat reads /proc/stat's aggregate cpu line and parses its counters.
24// Read failure or parse failure -> nil.
25func readProcStat() *metrics.CPUCounters {
26 b, err := os.ReadFile("/proc/stat")
27 if err != nil {
28 return nil
29 }
30 return metrics.ParseProcStat(string(b))
31}
32
33// readLoadAvg reads /proc/loadavg and parses the 1/5/15-minute load averages.
34// Any read or parse failure (too few fields, non-numeric values) -> all three
35// results nil, since a partial load average is not a value worth publishing
36// on its own.
37func readLoadAvg() (l1, l5, l15 *float64) {
38 b, err := os.ReadFile("/proc/loadavg")
39 if err != nil {
40 return nil, nil, nil
41 }
42 return metrics.ParseLoadAvg(string(b))
43}
44
45// cgroupSliceDir is the cgroup v2 directory where CARLOS units report their
46// resource usage. It is the default root for listCarlosUnits, which takes the
47// root as an argument only so the discovery walk below can be tested against
48// a temp-dir fixture instead of the live kernel.
49const cgroupSliceDir = "/sys/fs/cgroup/system.slice"
50
51// cgroupCarlosSliceDir is the second root listCarlosUnits and
52// listStampedUnits must scan: carlos-edge.service now lives in carlos.slice,
53// not system.slice, because the isolated-exec design (§4.3) moved the edge
54// there to share its memory wall with the exec-child instances it supervises
55// (see internal/host/units/carlos-edge.service's Slice=carlos.slice). On a
56// flat fleet — no tenant instances yet delegated their own cgroups — the
57// edge's own leaf is still the hierarchical total of everything under it,
58// delegated exec children included, so publishing that same
59// memory.current/cpu.stat/io.stat reading under the unchanged unit-name key
60// "carlos-edge.service" preserves the number operators already look at; only
61// the directory that number is read from has moved.
62const cgroupCarlosSliceDir = "/sys/fs/cgroup/carlos.slice"
63
64// unitCgroup is one discovered CARLOS unit: its systemd unit name (the key
65// everything downstream — sample maps, the console's activator.UnitName
66// match — uses) and the cgroup directory that unit's files actually live in.
67// The two are NOT interchangeable: only flat units sit at
68// <root>/<name>, and rebuilding the path from the name would miss every
69// tenant instance (see listCarlosUnits).
70type unitCgroup struct {
71 name string
72 dir string
73}
74
75// listCarlosUnits lists all active CARLOS systemd units under root by
76// filtering directory names for the "carlos-" prefix and ".service" suffix.
77// Read failure -> nil.
78//
79// Two levels are scanned, because systemd puts CARLOS's two kinds of unit in
80// two different places:
81//
82// - Flat, directly under root: the platform's fixed units
83// (carlos-edge.service, carlos-litestream, carlos-adopt,
84// carlos-restore-verify) — plain non-templated services land in
85// system.slice itself.
86// - Nested one level down, inside a "system-*.slice" child: INSTANTIATED
87// units. carlos-app@<host>.service sets no Slice= (see
88// internal/host/units/carlos-app@.service), so systemd places every
89// instance of the template in the implicit per-template slice
90// system.slice/system-carlos\x2dapp.slice/. Scanning only the top level
91// therefore finds every platform unit and not a single tenant — the exact
92// set the per-unit table and the owner app-metrics page exist to show.
93//
94// Only these two levels are walked: a unit's own cgroup directory has
95// children of its own on some systems, and nothing of ours ever nests deeper
96// than one slice.
97func listCarlosUnits(root string) []unitCgroup {
98 entries, err := os.ReadDir(root)
99 if err != nil {
100 return nil
101 }
102
103 var units []unitCgroup
104 for _, entry := range entries {
105 if !entry.IsDir() {
106 continue
107 }
108 name := entry.Name()
109 if isCarlosUnitName(name) {
110 units = append(units, unitCgroup{name: name, dir: filepath.Join(root, name)})
111 continue
112 }
113 if !strings.HasPrefix(name, "system-") || !strings.HasSuffix(name, ".slice") {
114 continue
115 }
116 sliceDir := filepath.Join(root, name)
117 nested, err := os.ReadDir(sliceDir)
118 if err != nil {
119 continue // an unreadable slice loses its units, never the whole scan
120 }
121 for _, child := range nested {
122 if !child.IsDir() || !isCarlosUnitName(child.Name()) {
123 continue
124 }
125 units = append(units, unitCgroup{name: child.Name(), dir: filepath.Join(sliceDir, child.Name())})
126 }
127 }
128 return units
129}
130
131// listStampedUnits finds the cgroup directories of operator-stamped bespoke
132// units — registry Route.Unit (#110), the routes the carlos- naming
133// convention above cannot see (an operator's console.service, tito's
134// titogo-canary@<tenant>.service). Read failure -> nil, same as
135// listCarlosUnits.
136//
137// Same two-level walk, for the same reason: a flat unit sits under root, a
138// template instance inside its implicit system-*.slice. The stamped NAME says
139// nothing about which of the two systemd chose, so both levels are matched
140// against the same wanted set.
141//
142// A stamped unit with no cgroup dir under root is simply not returned — no
143// reading is the honest report for a unit that isn't running, and the console
144// renders that absence rather than a guessed zero. Names arrive from the
145// registry already shaped by registry.ValidUnitName (the door validates; see
146// SetUnit), so nothing here re-validates them: an unmatchable name matches
147// nothing, which is the same outcome as a stopped unit.
148func listStampedUnits(root string, stamped []string) []unitCgroup {
149 if len(stamped) == 0 {
150 return nil
151 }
152 want := make(map[string]bool, len(stamped))
153 for _, u := range stamped {
154 want[u] = true
155 }
156
157 entries, err := os.ReadDir(root)
158 if err != nil {
159 return nil
160 }
161
162 var units []unitCgroup
163 for _, entry := range entries {
164 if !entry.IsDir() {
165 continue
166 }
167 name := entry.Name()
168 if want[name] {
169 units = append(units, unitCgroup{name: name, dir: filepath.Join(root, name)})
170 continue
171 }
172 if !strings.HasPrefix(name, "system-") || !strings.HasSuffix(name, ".slice") {
173 continue
174 }
175 sliceDir := filepath.Join(root, name)
176 nested, err := os.ReadDir(sliceDir)
177 if err != nil {
178 continue // an unreadable slice loses its units, never the whole scan
179 }
180 for _, child := range nested {
181 if !child.IsDir() || !want[child.Name()] {
182 continue
183 }
184 units = append(units, unitCgroup{name: child.Name(), dir: filepath.Join(sliceDir, child.Name())})
185 }
186 }
187 return units
188}
189
190// isCarlosUnitName reports whether a cgroup directory name is one of ours:
191// the "carlos-" prefix and the ".service" suffix, with at least one character
192// between them.
193func isCarlosUnitName(name string) bool {
194 return len(name) > len("carlos-.service") &&
195 strings.HasPrefix(name, "carlos-") &&
196 strings.HasSuffix(name, ".service")
197}
198
199// unitRaw is a single reading of a systemd unit's resource usage from its
200// cgroup v2 files. Each field represents a metric that may have failed to
201// read independently.
202type unitRaw struct {
203 cpuUsec *int64
204 memBytes *int64
205 memPeakBytes *int64
206 readBytes *int64
207 writeBytes *int64
208}
209
210// readUnitRaw reads a unit's resource usage from the cgroup v2 files in
211// unitPath — the directory listCarlosUnits reported for that unit, which for
212// an instantiated unit is NOT <cgroupSliceDir>/<name>. Each file read failure
213// sets only that field to nil; the others still read independently. Parse
214// failures also yield nil for that field only.
215func readUnitRaw(unitPath string) unitRaw {
216 // Read cpu.stat for usage_usec
217 raw := unitRaw{}
218 if b, err := os.ReadFile(filepath.Join(unitPath, "cpu.stat")); err == nil {
219 raw.cpuUsec = metrics.ParseCgroupCPUUsec(string(b))
220 }
221
222 // Read memory.current
223 if b, err := os.ReadFile(filepath.Join(unitPath, "memory.current")); err == nil {
224 raw.memBytes = metrics.ParseCgroupInt(string(b))
225 }
226
227 // Read memory.peak
228 if b, err := os.ReadFile(filepath.Join(unitPath, "memory.peak")); err == nil {
229 raw.memPeakBytes = metrics.ParseCgroupInt(string(b))
230 }
231
232 // Read io.stat for rbytes and wbytes
233 if b, err := os.ReadFile(filepath.Join(unitPath, "io.stat")); err == nil {
234 rbytes, wbytes := metrics.ParseCgroupIO(string(b))
235 raw.readBytes = rbytes
236 raw.writeBytes = wbytes
237 }
238
239 return raw
240}
241
242// listExecCgroups lists the per-host exec-child cgroups under the agent's
243// delegated subtree — the third scan root, alongside listCarlosUnits' two
244// levels. An empty root (no delegation armed) or a read failure -> nil. The
245// agent's own supervise leaf fails IsExecName and is skipped: that process
246// is already reported as carlos-edge.service. A dir whose cgroup.procs is
247// empty or unreadable is skipped too: a failed seed or an aborted wake
248// leaves the dir behind (until a later exit's rmdir or the next agent
249// start's sweep), and reporting it would render a host whose wake FAILED
250// as a live instance at zero — absence is the data, never a guessed zero.
251// readUnitRaw reads the included dirs unchanged — same cgroup v2 files,
252// same fail-toward-nil fields.
253func listExecCgroups(root string) []unitCgroup {
254 if root == "" {
255 return nil
256 }
257 entries, err := os.ReadDir(root)
258 if err != nil {
259 return nil
260 }
261 var units []unitCgroup
262 for _, entry := range entries {
263 if !entry.IsDir() || !cgroup.IsExecName(entry.Name()) {
264 continue
265 }
266 dir := filepath.Join(root, entry.Name())
267 procs, err := os.ReadFile(filepath.Join(dir, "cgroup.procs"))
268 if err != nil {
269 continue
270 }
271 // A generation parent (zero-downtime spec §7.3) holds no process of
272 // its own; its generations do. cgroup.events "populated 1" is the
273 // kernel's word that something under it is alive.
274 if len(strings.TrimSpace(string(procs))) == 0 && !subtreePopulated(dir) {
275 continue
276 }
277 units = append(units, unitCgroup{name: entry.Name(), dir: dir})
278 }
279 return units
280}
281
282// subtreePopulated reports whether dir's cgroup.events says a process is
283// alive anywhere under it. Unreadable is no: absence is the data.
284func subtreePopulated(dir string) bool {
285 b, err := os.ReadFile(filepath.Join(dir, "cgroup.events"))
286 return err == nil && strings.Contains(string(b), "populated 1")
287}
288