host: memwatch duty — MemAvailablePercent + PSI to CloudWatch once a minute, gated by CARLOS_HOST_METRICS (memory-pressure spec §2.4)
5 files changed,
+293
−13
go.mod+6 −5go.sum+10 −8internal/host/agent.go+3 −0internal/host/memwatch.go+176 −0internal/host/memwatch_test.go+98 −0
diff --git a/go.mod b/go.mod| index dd7e91d44..6f666b88e 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,16 +3,18 @@ module github.com/carlosframework/platform |
| go 1.26 |
| require ( |
| - github.com/aws/aws-sdk-go-v2 v1.43.6 |
| + github.com/aws/aws-sdk-go-v2 v1.44.0 |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| github.com/aws/aws-sdk-go-v2/credentials v1.19.6 |
| + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 |
| + github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0 |
| github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 |
| github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 |
| github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 |
| github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6 |
| github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 |
| - github.com/aws/smithy-go v1.27.8 |
| + github.com/aws/smithy-go v1.28.1 |
| github.com/benbjohnson/litestream v0.5.14 |
| golang.org/x/crypto v0.52.0 |
| golang.org/x/net v0.55.0 |
| @@ -21,10 +23,9 @@ require ( |
| require ( |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect |
| - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect |
| - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect |
| - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect |
| + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 // indirect |
| + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 // indirect |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect |
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect |
diff --git a/go.sum b/go.sum| index d8d482895..297f59edf 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -18,8 +18,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ |
| github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= |
| github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y= |
| github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= |
| -github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= |
| -github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= |
| +github.com/aws/aws-sdk-go-v2 v1.44.0 h1:4IbaHhtzy+4h37z4JQyO9a2QsiCml3CNYHtq5hIHigo= |
| +github.com/aws/aws-sdk-go-v2 v1.44.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= |
| @@ -30,14 +30,16 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBU |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 h1:9vWXHtaepwoAl/UuKzxwgOoJDXPCC3hvgNMfcmdS2Tk= |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18/go.mod h1:sKuUZ+MwUTuJbYvZ8pK0x10LvgcJK3Y4rmh63YBekwk= |
| -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= |
| -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= |
| -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= |
| -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= |
| +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 h1:UIXlbijuB2XK1Kr57fo8iIxCuaSHJzwZ1uo+2tbEYIk= |
| +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40/go.mod h1:wcEsL6jscjZjVUinb0Q5qD/GXOG1yT3GNfmT9HuDwzU= |
| +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 h1:xLQVRDs2NddDmK9BEyh5KSlJ1Gpy5/GIJXrV6WcVGAE= |
| +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40/go.mod h1:XRXnpFVFGLaEVK+olDdFIM1vNa04ETW452oFGEPUxAo= |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= |
| +github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0 h1:RdWMeCVaJfu4hCPyRNVQMKMP/Y5J04zB5LF45DmHhRg= |
| +github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0/go.mod h1:HfsU8PQ6mHewSJNS77IPAFMZZgFaYSHjAhSOAFXdu9M= |
| github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA= |
| github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5/go.mod h1:gy1JJMsby9XLbrwMhJcMu0IuMJF4DvDe4pD1wqMmwkk= |
| github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 h1:/6iRcqrC6k1rMA6uCZMzFE9inOrBpNmhbrZ90X8qH50= |
| @@ -64,8 +66,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlV |
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= |
| github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= |
| github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= |
| -github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= |
| -github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= |
| +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= |
| +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= |
| github.com/benbjohnson/litestream v0.5.14 h1:pu2RCUfEs2MfAvxSttNg/kVVzJspqg2sX/pPW3Elzxc= |
| github.com/benbjohnson/litestream v0.5.14/go.mod h1:aN5OtdkcCz07xVjCbmMWOGUSIdPumpqBYtdyrzNMhOI= |
| github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 0a9825ef8..6b7e05792 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -353,6 +353,9 @@ func RunAgent(args []string) { |
| go logshipLoop(logSink, status.Label(readMachineID())) |
| } |
| go statusLoop(reg) |
| + // The memory early-warning (memory-pressure spec §2.4): per box, gated |
| + // by CARLOS_HOST_METRICS — one log line and nothing else where unset. |
| + go memwatchLoop() |
| go domainsLoop(reg, peekAcmeCache(args)) |
| go instancesLoop(reg) |
| // The deployment-global election, armed BEFORE the loops it gates so a |
diff --git a/internal/host/memwatch.go b/internal/host/memwatch.go| new file mode 100644 |
| index 000000000..bf2ac4a8a |
| --- /dev/null |
| +++ b/internal/host/memwatch.go |
| @@ -0,0 +1,176 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" |
| + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| + "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" |
| +) |
| + |
| +// memwatch (memory-pressure spec §2.4) publishes two CloudWatch metrics a |
| +// minute so a box that is running out of memory pages someone BEFORE the |
| +// kernel starts thrashing — the state the flagship sat in for fifteen |
| +// minutes on 2026-08-28 with every AWS-side check green. The status |
| +// snapshot already carries memory to the bucket every five minutes, but a |
| +// bucket write alarms nobody; CloudWatch does. |
| +// |
| +// Carlos/Host MemAvailablePercent MemAvailable / MemTotal, /proc/meminfo |
| +// Carlos/Host MemoryPressureSome60 some avg60 from /proc/pressure/memory |
| +// |
| +// Both keyed by InstanceId (IMDS). The region-host module's alarm reads the |
| +// first; the second is the early warning — it climbs minutes before |
| +// MemAvailable hits the floor. |
| +// |
| +// Per-box, not a housekeeper duty: every box reports itself. Armed only by |
| +// CARLOS_HOST_METRICS=cloudwatch in host.env, so a Pi or a customer fleet |
| +// box never tries to reach an API it has no credentials for. |
| + |
| +const ( |
| + memwatchNamespace = "Carlos/Host" |
| + memwatchPeriod = time.Minute |
| +) |
| + |
| +// metricPutter is the one CloudWatch call this file makes. |
| +type metricPutter interface { |
| + PutMetricData(context.Context, *cloudwatch.PutMetricDataInput, ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error) |
| +} |
| + |
| +type memwatch struct { |
| + put metricPutter |
| + instanceID string |
| + mem func() (*int64, *int64) // MemTotal, MemAvailable in bytes |
| + pressure func() (string, error) // /proc/pressure/memory content |
| + logf func(string, ...any) |
| +} |
| + |
| +// memwatchLoop arms the duty when host.env asks for it. One log line either |
| +// way; a box that can't resolve its instance id or build a client logs why |
| +// and stays quiet rather than retrying — the operator set the env, so a |
| +// silent duty is a bug they'd want to see at arm time, in the journal. |
| +func memwatchLoop() { |
| + if v := strings.TrimSpace(os.Getenv("CARLOS_HOST_METRICS")); v != "cloudwatch" { |
| + if v != "" { |
| + log.Printf("agent: memwatch disabled: CARLOS_HOST_METRICS=%q (the only value is \"cloudwatch\")", v) |
| + } else { |
| + log.Printf("agent: memwatch disabled (CARLOS_HOST_METRICS unset)") |
| + } |
| + return |
| + } |
| + ctx := context.Background() |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + if err != nil { |
| + log.Printf("agent: memwatch disabled: load aws config: %v", err) |
| + return |
| + } |
| + id, err := imdsInstanceID(ctx, cfg) |
| + if err != nil { |
| + log.Printf("agent: memwatch disabled: instance id: %v", err) |
| + return |
| + } |
| + w := memwatch{ |
| + put: cloudwatch.NewFromConfig(cfg), |
| + instanceID: id, |
| + mem: readMemBytes, |
| + pressure: readPressureFile, |
| + logf: log.Printf, |
| + } |
| + log.Printf("agent: memwatch armed (%s, every %s, instance %s)", memwatchNamespace, memwatchPeriod, id) |
| + t := time.NewTicker(memwatchPeriod) |
| + defer t.Stop() |
| + for now := range t.C { |
| + w.tick(ctx, now) |
| + } |
| +} |
| + |
| +// tick reads, builds, puts. A failed read skips the tick (a zero would look |
| +// like a full box); a failed put is one line and the next tick tries again. |
| +func (w *memwatch) tick(ctx context.Context, now time.Time) { |
| + total, avail := w.mem() |
| + if total == nil || avail == nil || *total <= 0 { |
| + w.logf("memwatch: /proc/meminfo unreadable this tick — skipped") |
| + return |
| + } |
| + availPct := float64(*avail) / float64(*total) * 100 |
| + psi, hasPSI := 0.0, false |
| + if s, err := w.pressure(); err == nil { |
| + psi, hasPSI = parsePressureSome60(s) |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) |
| + defer cancel() |
| + if _, err := w.put.PutMetricData(ctx, memwatchData(w.instanceID, availPct, psi, hasPSI, now)); err != nil { |
| + w.logf("memwatch: put: %v", err) |
| + } |
| +} |
| + |
| +// memwatchData is the pure half: the exact PutMetricData input for one |
| +// tick. Standard resolution — a one-minute period at standard resolution |
| +// is free-tier territory; high resolution is not and buys nothing for a |
| +// three-minute alarm. |
| +func memwatchData(instanceID string, availPct, psi float64, hasPSI bool, now time.Time) *cloudwatch.PutMetricDataInput { |
| + dims := []types.Dimension{{Name: aws.String("InstanceId"), Value: aws.String(instanceID)}} |
| + data := []types.MetricDatum{{ |
| + MetricName: aws.String("MemAvailablePercent"), |
| + Dimensions: dims, |
| + Timestamp: aws.Time(now), |
| + Unit: types.StandardUnitPercent, |
| + Value: aws.Float64(availPct), |
| + }} |
| + if hasPSI { |
| + data = append(data, types.MetricDatum{ |
| + MetricName: aws.String("MemoryPressureSome60"), |
| + Dimensions: dims, |
| + Timestamp: aws.Time(now), |
| + Unit: types.StandardUnitPercent, |
| + Value: aws.Float64(psi), |
| + }) |
| + } |
| + return &cloudwatch.PutMetricDataInput{Namespace: aws.String(memwatchNamespace), MetricData: data} |
| +} |
| + |
| +// imdsInstanceID asks the metadata service which instance this is — the |
| +// dimension the alarm keys on. The IMDS uid-gate (harden-host) lets the |
| +// carlos user through by construction. |
| +func imdsInstanceID(ctx context.Context, cfg aws.Config) (string, error) { |
| + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) |
| + defer cancel() |
| + out, err := imds.NewFromConfig(cfg).GetInstanceIdentityDocument(ctx, &imds.GetInstanceIdentityDocumentInput{}) |
| + if err != nil { |
| + return "", err |
| + } |
| + return out.InstanceID, nil |
| +} |
| + |
| +// readPressureFile is the /proc/pressure/memory read; absent on a kernel |
| +// without PSI (CONFIG_PSI) — the tick then publishes MemAvailable alone. |
| +func readPressureFile() (string, error) { |
| + b, err := os.ReadFile("/proc/pressure/memory") |
| + return string(b), err |
| +} |
| + |
| +// parsePressureSome60 pulls "some ... avg60=N" out of /proc/pressure/memory. |
| +// Not-ok for any shape it doesn't recognise: a parse bug must not report a |
| +// calm box. |
| +func parsePressureSome60(s string) (float64, bool) { |
| + for _, line := range strings.Split(s, "\n") { |
| + fields := strings.Fields(line) |
| + if len(fields) == 0 || fields[0] != "some" { |
| + continue |
| + } |
| + for _, f := range fields[1:] { |
| + if v, ok := strings.CutPrefix(f, "avg60="); ok { |
| + n, err := strconv.ParseFloat(v, 64) |
| + return n, err == nil |
| + } |
| + } |
| + return 0, false |
| + } |
| + return 0, false |
| +} |
diff --git a/internal/host/memwatch_test.go b/internal/host/memwatch_test.go| new file mode 100644 |
| index 000000000..24ef0e436 |
| --- /dev/null |
| +++ b/internal/host/memwatch_test.go |
| @@ -0,0 +1,98 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| +) |
| + |
| +// TestParsePressureSome60 pins /proc/pressure/memory's shape: two lines, |
| +// "some" then "full", each with avg10/avg60/avg300/total. Only some/avg60 |
| +// is wanted. A kernel without PSI has no file at all — the caller handles |
| +// that — but a file with an unexpected shape reports not-ok rather than 0, |
| +// so a parse bug can't masquerade as a healthy box. |
| +func TestParsePressureSome60(t *testing.T) { |
| + good := "some avg10=0.00 avg60=12.50 avg300=3.10 total=171234\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=153521\n" |
| + if got, ok := parsePressureSome60(good); !ok || got != 12.5 { |
| + t.Fatalf("parse = (%v, %v), want (12.5, true)", got, ok) |
| + } |
| + for name, s := range map[string]string{ |
| + "empty": "", |
| + "full only": "full avg10=0.00 avg60=0.00 avg300=0.00 total=1\n", |
| + "no avg60": "some avg10=0.00 avg300=0.00 total=1\n", |
| + "not number": "some avg10=0.00 avg60=x avg300=0.00 total=1\n", |
| + } { |
| + if _, ok := parsePressureSome60(s); ok { |
| + t.Errorf("%s: parsed ok, want not-ok", name) |
| + } |
| + } |
| +} |
| + |
| +// TestMemwatchData: the tick's PutMetricData input. Two data points when |
| +// PSI is readable, one when it is not; namespace, dimension and units are |
| +// what the region-host module's alarm and IAM condition name — a drift |
| +// here is an alarm that never fires, silently. |
| +func TestMemwatchData(t *testing.T) { |
| + now := time.Date(2026, 8, 28, 11, 4, 0, 0, time.UTC) |
| + in := memwatchData("i-0123", 14.2, 30.5, true, now) |
| + if *in.Namespace != "Carlos/Host" { |
| + t.Fatalf("namespace %q", *in.Namespace) |
| + } |
| + if len(in.MetricData) != 2 { |
| + t.Fatalf("%d data points, want 2", len(in.MetricData)) |
| + } |
| + names := map[string]float64{} |
| + for _, d := range in.MetricData { |
| + names[*d.MetricName] = *d.Value |
| + if len(d.Dimensions) != 1 || *d.Dimensions[0].Name != "InstanceId" || *d.Dimensions[0].Value != "i-0123" { |
| + t.Errorf("%s: dimensions %+v", *d.MetricName, d.Dimensions) |
| + } |
| + if !d.Timestamp.Equal(now) { |
| + t.Errorf("%s: timestamp %v", *d.MetricName, d.Timestamp) |
| + } |
| + } |
| + if names["MemAvailablePercent"] != 14.2 || names["MemoryPressureSome60"] != 30.5 { |
| + t.Errorf("values %v", names) |
| + } |
| + if in := memwatchData("i-0123", 14.2, 0, false, now); len(in.MetricData) != 1 || *in.MetricData[0].MetricName != "MemAvailablePercent" { |
| + t.Errorf("without PSI: %+v", in.MetricData) |
| + } |
| +} |
| + |
| +type fakePutter struct { |
| + calls []*cloudwatch.PutMetricDataInput |
| +} |
| + |
| +func (f *fakePutter) PutMetricData(_ context.Context, in *cloudwatch.PutMetricDataInput, _ ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error) { |
| + f.calls = append(f.calls, in) |
| + return &cloudwatch.PutMetricDataOutput{}, nil |
| +} |
| + |
| +// TestMemwatchTick: one tick, one call, built from the readers it is |
| +// handed. An unreadable meminfo is a skipped tick, not a zero. |
| +func TestMemwatchTick(t *testing.T) { |
| + f := &fakePutter{} |
| + w := memwatch{ |
| + put: f, |
| + instanceID: "i-0123", |
| + mem: func() (*int64, *int64) { tot, av := int64(4000), int64(1000); return &tot, &av }, |
| + pressure: func() (string, error) { |
| + return "some avg10=0 avg60=7.5 avg300=0 total=0\nfull avg10=0 avg60=0 avg300=0 total=0\n", nil |
| + }, |
| + logf: t.Logf, |
| + } |
| + w.tick(context.Background(), time.Unix(0, 0)) |
| + if len(f.calls) != 1 || len(f.calls[0].MetricData) != 2 { |
| + t.Fatalf("calls %+v", f.calls) |
| + } |
| + if v := *f.calls[0].MetricData[0].Value; v != 25 { |
| + t.Errorf("MemAvailablePercent = %v, want 25", v) |
| + } |
| + w.mem = func() (*int64, *int64) { return nil, nil } |
| + w.tick(context.Background(), time.Unix(0, 0)) |
| + if len(f.calls) != 1 { |
| + t.Errorf("a tick with unreadable meminfo must not put: %d calls", len(f.calls)) |
| + } |
| +} |