carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "errors"
6 "log"
7 "net/http"
8 "os"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/aws/aws-sdk-go-v2/aws"
14 "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
15 "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types"
16 "github.com/carlosframework/platform/internal/rootaws"
17)
18
19// memwatch (memory-pressure spec §2.4) publishes two CloudWatch metrics a
20// minute so a box that is running out of memory pages someone BEFORE the
21// kernel starts thrashing — the state the flagship sat in for fifteen
22// minutes on 2026-08-28 with every AWS-side check green. The status
23// snapshot already carries memory to the bucket every five minutes, but a
24// bucket write alarms nobody; CloudWatch does.
25//
26// Carlos/Host MemAvailablePercent MemAvailable / MemTotal, /proc/meminfo
27// Carlos/Host MemoryPressureSome60 some avg60 from /proc/pressure/memory
28//
29// Both keyed by InstanceId (IMDS). The region-host module's alarm reads the
30// first; the second is the early warning — it climbs minutes before
31// MemAvailable hits the floor.
32//
33// Per-box, not a housekeeper duty: every box reports itself. Armed only by
34// CARLOS_HOST_METRICS=cloudwatch in host.env, so a Pi or a customer fleet
35// box never tries to reach an API it has no credentials for.
36
37const (
38 memwatchNamespace = "Carlos/Host"
39 memwatchPeriod = time.Minute
40)
41
42// metricPutter is the one CloudWatch call this file makes.
43type metricPutter interface {
44 PutMetricData(context.Context, *cloudwatch.PutMetricDataInput, ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error)
45}
46
47// putMetricFunc is the metricPutter over one bound method value. Storing the
48// *cloudwatch.Client itself in the interface would make the linker keep every
49// exported CloudWatch method once html/template is linked in (discussion
50// #275); a method value keeps PutMetricData alone.
51type putMetricFunc func(context.Context, *cloudwatch.PutMetricDataInput, ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error)
52
53func (f putMetricFunc) PutMetricData(ctx context.Context, in *cloudwatch.PutMetricDataInput, opts ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error) {
54 return f(ctx, in, opts...)
55}
56
57type memwatch struct {
58 put metricPutter
59 instanceID string
60 mem func() (*int64, *int64) // MemTotal, MemAvailable in bytes
61 pressure func() (string, error) // /proc/pressure/memory content
62 logf func(string, ...any)
63 // lastPutErr is the text of the put failure last logged, "" while
64 // puts succeed. Failures log on CHANGE (and recovery logs once), not
65 // once a minute: a box rolled before its IAM grant lands would
66 // otherwise write 1,440 identical lines a day.
67 lastPutErr string
68}
69
70// memwatchArmed is the host.env gate, split out so the three answers it
71// can give are table-testable: armed, off (unset), or off with a value
72// this duty doesn't understand — the last is the one an operator wants to
73// see in the journal, because they typed it.
74func memwatchArmed(getenv func(string) string) (armed bool, why string) {
75 switch v := strings.TrimSpace(getenv("CARLOS_HOST_METRICS")); v {
76 case "cloudwatch":
77 return true, ""
78 case "":
79 return false, "CARLOS_HOST_METRICS unset"
80 default:
81 return false, "CARLOS_HOST_METRICS=" + strconv.Quote(v) + ` (the only value is "cloudwatch")`
82 }
83}
84
85// memwatchLoop arms the duty when host.env asks for it. One log line either
86// way; a box that can't resolve its instance id or build a client logs why
87// and stays quiet rather than retrying — the operator set the env, so a
88// silent duty is a bug they'd want to see at arm time, in the journal.
89// Process-lifetime, like statusLoop: nothing stops the ticker.
90func memwatchLoop() {
91 if armed, why := memwatchArmed(os.Getenv); !armed {
92 log.Printf("agent: memwatch disabled (%s)", why)
93 return
94 }
95 ctx := context.Background()
96 // Region comes from the default chain — AWS_REGION in host.env, else
97 // IMDS. A compatible-store deployment that points AWS_REGION at its
98 // own object store (internal/lease/endpoint.go's note) would aim the
99 // CloudWatch client at nonsense, which is one more reason this duty
100 // is opt-in per box rather than on wherever credentials resolve.
101 cfg, err := rootaws.LoadDefaultConfig(ctx)
102 if err != nil {
103 log.Printf("agent: memwatch disabled: load aws config: %v", err)
104 return
105 }
106 // The metric goes to the box's OWN region, not host.env's AWS_REGION:
107 // the flagship sets AWS_REGION=eu-west-1 fleet-wide (it names the
108 // deployment bucket's region), and on the first roll all four boxes
109 // dutifully published into Ireland — while the region-host module
110 // creates each box's alarm in the box's region, so three alarms would
111 // have watched an empty namespace forever. IMDS placement wins; "" (not
112 // on EC2) keeps the chain's answer.
113 if r := readPlacementRegion(); r != "" {
114 cfg.Region = r
115 }
116 id, err := imdsInstanceID()
117 if err != nil {
118 log.Printf("agent: memwatch disabled: instance id: %v", err)
119 return
120 }
121 w := memwatch{
122 put: putMetricFunc(cloudwatch.NewFromConfig(cfg).PutMetricData),
123 instanceID: id,
124 mem: readMemBytes,
125 pressure: readPressureFile,
126 logf: log.Printf,
127 }
128 log.Printf("agent: memwatch armed (%s in %s, every %s, instance %s)", memwatchNamespace, cfg.Region, memwatchPeriod, id)
129 for now := range time.Tick(memwatchPeriod) {
130 w.tick(ctx, now)
131 }
132}
133
134// tick reads, builds, puts. A failed read skips the tick (a zero would look
135// like a full box); a failed put is logged when it first happens or changes,
136// and the next tick tries again.
137func (w *memwatch) tick(ctx context.Context, now time.Time) {
138 total, avail := w.mem()
139 if total == nil || avail == nil || *total <= 0 {
140 w.logf("memwatch: /proc/meminfo unreadable this tick — skipped")
141 return
142 }
143 availPct := float64(*avail) / float64(*total) * 100
144 psi, hasPSI := 0.0, false
145 if s, err := w.pressure(); err == nil {
146 psi, hasPSI = parsePressureSome60(s)
147 }
148 ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
149 defer cancel()
150 _, err := w.put.PutMetricData(ctx, memwatchData(w.instanceID, availPct, psi, hasPSI, now))
151 switch {
152 case err != nil && err.Error() != w.lastPutErr:
153 w.lastPutErr = err.Error()
154 w.logf("memwatch: put: %v (logged once until it changes)", err)
155 case err == nil && w.lastPutErr != "":
156 w.lastPutErr = ""
157 w.logf("memwatch: put recovered")
158 }
159}
160
161// memwatchData is the pure half: the exact PutMetricData input for one
162// tick. Standard resolution — a one-minute period at standard resolution
163// is free-tier territory; high resolution is not and buys nothing for a
164// three-minute alarm.
165func memwatchData(instanceID string, availPct, psi float64, hasPSI bool, now time.Time) *cloudwatch.PutMetricDataInput {
166 dims := []types.Dimension{{Name: aws.String("InstanceId"), Value: aws.String(instanceID)}}
167 data := []types.MetricDatum{{
168 MetricName: aws.String("MemAvailablePercent"),
169 Dimensions: dims,
170 Timestamp: aws.Time(now),
171 Unit: types.StandardUnitPercent,
172 Value: aws.Float64(availPct),
173 }}
174 if hasPSI {
175 data = append(data, types.MetricDatum{
176 MetricName: aws.String("MemoryPressureSome60"),
177 Dimensions: dims,
178 Timestamp: aws.Time(now),
179 Unit: types.StandardUnitPercent,
180 Value: aws.Float64(psi),
181 })
182 }
183 return &cloudwatch.PutMetricDataInput{Namespace: aws.String(memwatchNamespace), MetricData: data}
184}
185
186// imdsInstanceID asks the metadata service which instance this is — the
187// dimension the alarm keys on — through the same IMDSv2 token dance
188// readPlacementRegion uses (imdsToken/imdsLeaf). The IMDS uid-gate
189// (harden-host) lets the carlos user through by construction.
190func imdsInstanceID() (string, error) {
191 client := &http.Client{Timeout: 2 * time.Second}
192 token := imdsToken(client)
193 if token == "" {
194 return "", errors.New("no IMDSv2 token (not on EC2, or IMDS unreachable)")
195 }
196 id, code := imdsLeaf(client, token, "/latest/meta-data/instance-id")
197 if code != http.StatusOK || id == "" {
198 return "", errors.New("instance-id leaf did not answer 200")
199 }
200 return id, nil
201}
202
203// readPressureFile is the /proc/pressure/memory read; absent on a kernel
204// without PSI (CONFIG_PSI) — the tick then publishes MemAvailable alone.
205func readPressureFile() (string, error) {
206 b, err := os.ReadFile("/proc/pressure/memory")
207 return string(b), err
208}
209
210// parsePressureSome60 pulls "some ... avg60=N" out of /proc/pressure/memory.
211// Not-ok for any shape it doesn't recognise: a parse bug must not report a
212// calm box.
213func parsePressureSome60(s string) (float64, bool) {
214 for _, line := range strings.Split(s, "\n") {
215 fields := strings.Fields(line)
216 if len(fields) == 0 || fields[0] != "some" {
217 continue
218 }
219 for _, f := range fields[1:] {
220 if v, ok := strings.CutPrefix(f, "avg60="); ok {
221 n, err := strconv.ParseFloat(v, 64)
222 return n, err == nil
223 }
224 }
225 return 0, false
226 }
227 return 0, false
228}
229