carlos / platform Public

geoip plan 2: the feed and the geoip alert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 419d897547b52c09489f01bf49b18f5ad41dcd26 parent 9146bd0
1 file changed, +242 −0
  • docs/superpowers/plans/2026-10-07-platform-geoip-plan2.md +242 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-07-platform-geoip-plan2.md b/docs/superpowers/plans/2026-10-07-platform-geoip-plan2.md
new file mode 100644
index 000000000..6c649a60e
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-07-platform-geoip-plan2.md
@@ -0,0 +1,242 @@
+# Platform GeoIP, plan 2: the feed and the `geoip` alert
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (this plan is executed natively, as plan 1 was). Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** The console keeps the published database current by itself, fetching DB-IP Lite or GeoLite2. Every box raises a `geoip` alert, through the existing box-alert pipeline, when its database is stale, expiring, expired or not loaded, or (on the console's box only) when the feed keeps failing.
+
+**Architecture:**
+- **The fetcher** lives in the console (`internal/console/geoipfeed.go`). It is the one control-plane process, so there is only one fetcher. It reuses plan 1's publish slot (`geoipPublisher.Stage`/`Commit`), adding two feed-only rules: the type must match the configured feed, and an edition must be newer than the last one from the same feed.
+- **Archives** are opened by pure, capped readers in `internal/geoip/archive.go`.
+- **The feed's state** goes to `control/geoip/feed.json` after every check, naming the console's box.
+- **The alert** is a fifth box rule beside disk, mem, cpu and lag. Each mirror computes a reason code. `metrics.Evaluate` applies its sustain, and the host's alert sink and the console's alert watcher deliver it, unchanged.
+
+**Tech Stack:** Go stdlib (`compress/gzip`, `archive/tar`, `net/http`), plan 1's packages.
+
+**Spec:** `docs/superpowers/specs/2026-10-06-platform-geoip-design.md`, §10 (rev 11 or later). Plan 1 is built on this branch; its code is the base.
+
+## Global Constraints
+
+- **Console env:**
+ - `CONSOLE_GEOIP_FEED`: one of `dbip-city-lite`, `dbip-country-lite`, `geolite2-city`, `geolite2-country`;
+ - `CONSOLE_GEOIP_MAXMIND_ACCOUNT` and `CONSOLE_GEOIP_MAXMIND_KEY`.
+ - An unknown name, or GeoLite2 without both credentials, is logged once at startup, and the feed stays off.
+- **Feed name to type:**
+ - `dbip-city-lite` → `DBIP-City-Lite`
+ - `dbip-country-lite` → `DBIP-Country-Lite`
+ - `geolite2-city` → `GeoLite2-City`
+ - `geolite2-country` → `GeoLite2-Country`
+- **URLs:**
+ - DB-IP: `https://download.db-ip.com/free/dbip-<city|country>-lite-YYYY-MM.mmdb.gz`
+ - MaxMind: `https://download.maxmind.com/geoip/databases/GeoLite2-<City|Country>/download?suffix=tar.gz`, with `suffix=tar.gz.sha256` for the checksum.
+- **Caps:**
+ - compressed download 512 MiB, enforced while copying;
+ - all expanded tar bytes 1.5 GiB, skipped members included;
+ - the `.mmdb` itself 1 GiB (plan 1's `copyCapped`);
+ - checksum file 4 KiB;
+ - `feed.json` 64 KiB;
+ - each check under the publisher's 15-minute deadline.
+- **Redirects:** at most 3, `https` only. The Basic `Authorization` header is dropped on a cross-host redirect. The key never appears in a URL or a log line.
+- **Schedule:** a first check 5 minutes after start, then daily with up to an hour of jitter.
+- **Seven** consecutive 404/410 answers raise the alert's feed reason.
+- **Alert codes:** the rule's `Value` is the reason code; 0 = none.
+
+ | Code | Reason |
+ |---|---|
+ | 1 | loaded database older than 45 days |
+ | 2 | GeoLite2 within 9 days of D1 expiry (21 days old) |
+ | 3 | the pointer is expired, marked or by the clock |
+ | 4 | a live pointer has not been loaded for over an hour |
+ | 5 | the feed has failed 7 times in a row (console box only) |
+
+ When several hold at once, the most severe wins: 3, then 4, then 2, then 1, then 5.
+- **The rule** fires after the reason has held for 30 minutes (`Rules.GeoIPSustain`). It clears at once when the code returns to 0.
+- **Copy:** every new alert sentence and log line follows the house voice (no em dashes). The alert sentences join plan 1's copy review.
+
+## Review Focus
+
+1. **A feed that serves a different product, an older edition, or a truncated file** must leave the published database untouched.
+2. **A redirect** to another host must never carry the MaxMind credentials.
+3. **A decompression bomb, or a tar padded with junk members,** must stop at the caps without filling the disk.
+4. **The feed reason** must fire on exactly one box, the console's, and must still fire on a fresh deployment where the feed has never succeeded.
+5. **`off`** must clear every pointer-related reason; expiry must not.
+
+---
+
+### Task 1: `internal/geoip/archive.go` and `feed.go`
+
+**Files:** Create `internal/geoip/archive.go`, `internal/geoip/feed.go`, `internal/geoip/archive_test.go`, `internal/geoip/feed_test.go`.
+
+**Produces:**
+
+```go
+// feed.go
+type FeedSpec struct{ Name, Type, Provider string } // Provider: "dbip" | "maxmind"
+func ParseFeed(name string) (FeedSpec, bool)
+func DBIPURL(spec FeedSpec, month time.Time) string
+func MaxMindURL(spec FeedSpec, suffix string) string
+type FeedStatus struct {
+ V int `json:"v"`
+ Feed string `json:"feed"`
+ Box string `json:"box"` // the console's box label: the one box that alerts on feed failure
+ LastCheck time.Time `json:"last_check"`
+ LastSuccess time.Time `json:"last_success,omitempty"`
+ LastEdition string `json:"last_edition,omitempty"`
+ ConsecutiveFailures int `json:"consecutive_failures"`
+ LastFailure string `json:"last_failure,omitempty"` // a fixed category, never an error text
+ ConsecutiveNotFound int `json:"consecutive_not_found"`
+}
+const FeedNotFoundAlert = 7
+func ParseFeedStatus(raw []byte) (FeedStatus, error)
+func (s FeedStatus) Failing() bool // ConsecutiveFailures >= 7 || ConsecutiveNotFound >= 7
+
+// archive.go
+var ErrTooLarge = errors.New("geoip archive: larger than its cap")
+func CappedReader(r io.Reader, max int64) io.Reader // returns ErrTooLarge once more than max bytes are read
+func GunzipReader(r io.Reader, maxExpanded int64) (io.Reader, error)
+func TarGzMMDB(r io.Reader, maxExpanded int64) (io.Reader, error) // the first regular *.mmdb member; every expanded byte counts
+```
+
+- [ ] **Step 1: Tests.**
+ - `ParseFeed` returns the four names with their types and providers, and rejects anything else.
+ - `DBIPURL` for `dbip-city-lite` in 2026-10 is `https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz`.
+ - `CappedReader` errors with `ErrTooLarge` at max+1 and passes exactly max.
+ - `GunzipReader` on a 10 MiB run of zeros gzipped, with a cap of 1 MiB, gives `ErrTooLarge` after reading about 1 MiB, never the full expansion.
+ - `TarGzMMDB` returns the bytes of `db.mmdb` from an archive whose first member is a 2 MiB `README`, and errors with `ErrTooLarge` when the cap is 1 MiB.
+ - `TarGzMMDB` on an archive with no `.mmdb` member gives a clear error.
+ - `FeedStatus` round-trips through JSON.
+ - `Failing` crosses at exactly 7.
+ - Build the test archives in memory with `archive/tar` and `compress/gzip`.
+- [ ] **Step 2: Implement.**
+ - `CappedReader` counts bytes, and once the count exceeds max it returns `ErrTooLarge`.
+ - `GunzipReader` is `CappedReader(gzip.NewReader(r), maxExpanded)`.
+ - `TarGzMMDB` wraps the gunzipped stream in one `CappedReader(…, maxExpanded)`, so skipped members count. It walks `tar.NewReader` to the first `TypeReg` member whose name ends `.mmdb` (path base) and returns that member's reader.
+- [ ] **Step 3:** `go test ./internal/geoip`, then commit.
+
+### Task 2: publish rules for the feed
+
+**Files:** Modify `internal/console/geoippublish.go`, `geoippublish_test.go`.
+
+**Produces:** `geoipPublishOpts` gains `RequireType string` (refuse unless `db.Source()` equals it) and `Monotonic bool`. With `Monotonic` set, the publish is refused when the current pointer has the same `Origin` and the candidate's `BuildEpoch` is not after the current one's. The feed sets both and never sets `Force`.
+
+- [ ] **Step 1: Tests.**
+ - `RequireType: "GeoIP2-Country"` refuses the City fixture with the category "the feed returned a GeoIP2-City database, not GeoIP2-Country".
+ - With `Monotonic`, `Origin: "feed:x"`, publishing the City fixture twice refuses the second as "not newer than the published edition". The pointer is unchanged.
+ - With `Monotonic` against a current pointer whose `Origin` is `manual`, the publish proceeds.
+- [ ] **Step 2: Implement it in `Commit`,** after the country probe and before the shrink check.
+- [ ] **Step 3:** Run the tests, then commit.
+
+### Task 3: the feed
+
+**Files:** Create `internal/console/geoipfeed.go` and `geoipfeed_test.go`. Modify `internal/console/server.go` (Config `GeoipFeed`, `GeoipMaxMindAccount`, `GeoipMaxMindKey` and `BoxLabel`; start the feed from `StartGeoipJobs`), `cmd/console/main.go` (read the env; `BoxLabel: status.Label(<machine id>)`, reading `/etc/machine-id` the way `internal/host/probes.go` does).
+
+**Produces:**
+
+```go
+type geoipFeeder struct {
+ pub *geoipPublisher
+ store release.Store
+ spec geoip.FeedSpec
+ account, key string
+ box string
+ client *http.Client // CheckRedirect: geoipFeedRedirect
+ now func() time.Time
+ dbipBase, maxmindBase string // test seams; "" = the real hosts
+}
+func newGeoipFeeder(cfg Config, pub *geoipPublisher) (*geoipFeeder, bool) // false = no feed (unset or misconfigured, logged once)
+func (f *geoipFeeder) Check(ctx context.Context) error // one check; always writes feed.json
+func (f *geoipFeeder) run(ctx context.Context)
+func geoipFeedRedirect(req *http.Request, via []*http.Request) error // ≤3, https only, drops Authorization cross-host
+```
+
+`Check`:
+1. Read the pointer and `feed.json` (capped).
+2. **DB-IP.**
+ - The edition is this month's `YYYY-MM`. If the pointer's `Origin` is this feed and its `Edition` is this month, the check is done (a success, nothing to do).
+ - Otherwise GET this month's URL, and on a 404 last month's. Only when there is no pointer, or the pointer came from elsewhere, is last month tried, because a pointer from this feed already holds last month.
+ - A 404 or 410 on every candidate is a "not out yet" check. `ConsecutiveNotFound` goes up by one; it is not a failure.
+3. **MaxMind.**
+ - HEAD the archive URL with Basic auth. If `Last-Modified` equals the pointer's `Edition` (same feed origin), the check is done.
+ - Otherwise GET `suffix=tar.gz.sha256`: the first 64 hex characters, capped at 4 KiB.
+ - Then GET the archive.
+4. **Stream the body:**
+ - `CappedReader(body, 512 MiB)`, teed into a sha256 hasher;
+ - then `GunzipReader` (DB-IP) or `TarGzMMDB` (MaxMind), capped at 1.5 GiB;
+ - then `pub.Stage`. `errGeoipBusy` skips this check; it is not a failure.
+5. **MaxMind checksum:** drain the rest of the archive through the tee (capped), and compare the archive's sha256 with the published one. On a mismatch, `Abandon` and record the failure "checksum".
+6. **Commit** with `geoipPublishOpts{Origin: "feed:" + name, Edition: edition, RequireType: spec.Type, Monotonic: true}`.
+7. **Record the outcome.** Success sets `LastSuccess` and `LastEdition`, and zeroes both counters. A refusal or error increments `ConsecutiveFailures` and sets `LastFailure` to a fixed category:
+ - `download` / `not found` / `too large` / `checksum` / `invalid` / `wrong type` / `not newer` / `expired` / `store`.
+8. **Write `feed.json`** with `Box: f.box`, on every path.
+
+`run`: wait 5 minutes, then check; then wait 24 hours plus up to 1 hour of jitter, and check again. If `CONSOLE_GEOIP_FEED` is unset at startup, delete any `feed.json`, so a switched-off feed alerts nobody.
+
+- [ ] **Step 1: Tests,** against an `httptest.NewTLSServer` standing in for both providers. Its client is the server's client, wrapped with `geoipFeedRedirect`. Use `dbipBase`/`maxmindBase` pointing at it, and the gzip/tar.gz of the GeoIP2 fixtures. Feed types are matched through `RequireType`, so use a `FeedSpec{Type: "GeoIP2-City"}` built by hand:
+ - **DB-IP:** a new edition is published, `feed.json` says success, and the edition is this month. A second check makes no GET at all, because it's the same month and the same origin.
+ - **404 handling:** this month 404s and last month serves; with no pointer, last month is published. Every candidate 404s and `ConsecutiveNotFound` goes up by one each check; after 7, `FeedStatus.Failing()` is true.
+ - **MaxMind:**
+ - The HEAD with an unchanged `Last-Modified` makes no GET.
+ - A changed one fetches `.sha256` and then the archive, and publishes.
+ - A wrong `.sha256` records "checksum", and the pointer is unchanged.
+ - The test server asserts that every request carries Basic auth, and that the key appears in no URL.
+ - **Redirect:** the archive URL redirects to a second `httptest` TLS server, on another host. That server sees no `Authorization` header. A 4th redirect is refused.
+ - **Bomb:** a gzip of 2 GiB of zeros (generate it streamed) is refused as "too large", with the staging directory empty afterwards. For speed, make the expanded cap a field set to 1 MiB in the test.
+ - **A feed type mismatch** is refused as "wrong type", even with no pointer at all (the first publish).
+ - **A busy slot** (`pub.mu` held) skips the check without counting a failure.
+- [ ] **Step 2:** Implement it.
+- [ ] **Step 3:** Run the tests, then commit.
+
+### Task 4: the mirror's alert code and `feed.json`
+
+**Files:** Modify `internal/edge/geoipmirror.go`, `internal/edge/geoipmirror_test.go`.
+
+**Produces:**
+- `geoMirror` tracks `wantSince` (when the pointer first named a sha that isn't loaded) and `pointerExpired`. It also reads `feed.json` (capped, best effort) on each poll, keeping `feedFailing` and `feedBox`.
+- `func (m *geoMirror) AlertCode(now time.Time, box string) float64` implements the priority above.
+- `func (e *Edge) GeoIPAlertCode(now time.Time) float64` uses the edge's box label (`WithBoxLabel`). It returns 0 when there is no mirror.
+
+- [ ] **Step 1: Tests:**
+ - each code from a constructed state;
+ - code 4 only after an hour;
+ - code 5 only when `feed.json`'s box equals the edge's label, and with no pointer at all;
+ - code 3 for an expired pointer;
+ - `off` (no pointer) gives 0 even with a feed failing elsewhere;
+ - the priority when 2 and 4 both hold.
+- [ ] **Step 2:** Implement it.
+- [ ] **Step 3:** Run the tests, then commit.
+
+### Task 5: the `geoip` rule
+
+**Files:** Modify:
+- `internal/metrics/alert.go`: `Observation.GeoIP *float64`; `Rules.GeoIPSustain` (default 30 minutes); `evalFrac("geoip", obs.GeoIP, 0.5, r.GeoIPSustain)`;
+- `internal/host/metrics_loop.go`: fill `obs.GeoIP` from a published source, the same pattern as `geoIPSource`;
+- `internal/host/agent.go`: publish `e.GeoIPAlertCode`;
+- `internal/host/alertsink.go`: the `geoip` cases.
+
+Alert sentences, sent to copy review with plan 1's batch:
+
+| Code | Firing | Clearing |
+|---|---|---|
+| 1 | `the geoip database on %s is more than 45 days old` | `geoip on %s is back to normal` |
+| 2 | `the GeoLite2 database on %s expires within 9 days` | the same |
+| 3 | `the geoip database on %s has expired and was removed` | the same |
+| 4 | `%s has not loaded the published geoip database for over an hour` | the same |
+| 5 | `the geoip feed has failed 7 times in a row (seen from %s)` | the same |
+
+`alertLevel` has no level for `geoip`, and `alertSustain` returns `GeoIPSustain`.
+
+- [ ] **Step 1: Tests.**
+ - `metrics.Evaluate` fires geoip after 30 minutes at code 1 and not before. It clears on the first 0. A nil reading changes nothing.
+ - The alert sink renders each code's sentence.
+ - The heartbeat-adjacent test: with no edge published, `obs.GeoIP` is nil.
+- [ ] **Step 2:** Implement it.
+- [ ] **Step 3:** Run the tests outside the sandbox for `internal/host`, then commit.
+
+### Task 6: feed status in `carlos system geoip status`
+
+**Files:** Modify `internal/console/apisystemgeoip.go` (add `feed` from `feed.json`: name, last check, last success, edition, failures, last failure category), `system_geoip.go` (print one line: `feed: <name>, last success <date>` or `feed: <name>, failing (<n> in a row, last: <category>)`), plus tests.
+
+- [ ] **Steps:** test, implement, then commit.
+
+### Final gate
+
+Run the full gate per memory, check the cross-compile, push, and wait for CI. Then an Astra code review of plan 2's diff. Add the new strings to the copy review.