carlos / platform Public
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.jsonafter 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.Evaluateapplies 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 ofdbip-city-lite,dbip-country-lite,geolite2-city,geolite2-country; -CONSOLE_GEOIP_MAXMIND_ACCOUNTandCONSOLE_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, withsuffix=tar.gz.sha256for the checksum. - Caps: - compressed download 512 MiB, enforced while copying; - all expanded tar bytes 1.5 GiB, skipped members included; - the
.mmdbitself 1 GiB (plan 1'scopyCapped); - checksum file 4 KiB; -feed.json64 KiB; - each check under the publisher's 15-minute deadline. - Redirects: at most 3,
httpsonly. The BasicAuthorizationheader 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
Valueis 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
- A feed that serves a different product, an older edition, or a truncated file must leave the published database untouched.
- A redirect to another host must never carry the MaxMind credentials.
- A decompression bomb, or a tar padded with junk members, must stop at the caps without filling the disk.
- 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.
offmust 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:
// 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. -
ParseFeedreturns the four names with their types and providers, and rejects anything else. -DBIPURLfordbip-city-litein 2026-10 ishttps://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz. -CappedReadererrors withErrTooLargeat max+1 and passes exactly max. -GunzipReaderon a 10 MiB run of zeros gzipped, with a cap of 1 MiB, givesErrTooLargeafter reading about 1 MiB, never the full expansion. -TarGzMMDBreturns the bytes ofdb.mmdbfrom an archive whose first member is a 2 MiBREADME, and errors withErrTooLargewhen the cap is 1 MiB. -TarGzMMDBon an archive with no.mmdbmember gives a clear error. -FeedStatusround-trips through JSON. -Failingcrosses at exactly 7. - Build the test archives in memory witharchive/tarandcompress/gzip. - [ ] Step 2: Implement. -
CappedReadercounts bytes, and once the count exceeds max it returnsErrTooLarge. -GunzipReaderisCappedReader(gzip.NewReader(r), maxExpanded). -TarGzMMDBwraps the gunzipped stream in oneCappedReader(…, maxExpanded), so skipped members count. It walkstar.NewReaderto the firstTypeRegmember 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". - WithMonotonic,Origin: "feed:x", publishing the City fixture twice refuses the second as "not newer than the published edition". The pointer is unchanged. - WithMonotonicagainst a current pointer whoseOriginismanual, 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:
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-hostCheck:
- Read the pointer and
feed.json(capped). - DB-IP. - The edition is this month's
YYYY-MM. If the pointer'sOriginis this feed and itsEditionis 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.ConsecutiveNotFoundgoes up by one; it is not a failure. - MaxMind. - HEAD the archive URL with Basic auth. If
Last-Modifiedequals the pointer'sEdition(same feed origin), the check is done. - Otherwise GETsuffix=tar.gz.sha256: the first 64 hex characters, capped at 4 KiB. - Then GET the archive. - Stream the body: -
CappedReader(body, 512 MiB), teed into a sha256 hasher; - thenGunzipReader(DB-IP) orTarGzMMDB(MaxMind), capped at 1.5 GiB; - thenpub.Stage.errGeoipBusyskips this check; it is not a failure. - 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,
Abandonand record the failure "checksum". - Commit with
geoipPublishOpts{Origin: "feed:" + name, Edition: edition, RequireType: spec.Type, Monotonic: true}. - Record the outcome. Success sets
LastSuccessandLastEdition, and zeroes both counters. A refusal or error incrementsConsecutiveFailuresand setsLastFailureto a fixed category: -download/not found/too large/checksum/invalid/wrong type/not newer/expired/store. - Write
feed.jsonwithBox: 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.NewTLSServerstanding in for both providers. Its client is the server's client, wrapped withgeoipFeedRedirect. UsedbipBase/maxmindBasepointing at it, and the gzip/tar.gz of the GeoIP2 fixtures. Feed types are matched throughRequireType, so use aFeedSpec{Type: "GeoIP2-City"}built by hand: - DB-IP: a new edition is published,feed.jsonsays 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 andConsecutiveNotFoundgoes up by one each check; after 7,FeedStatus.Failing()is true. - MaxMind: - The HEAD with an unchangedLast-Modifiedmakes no GET. - A changed one fetches.sha256and then the archive, and publishes. - A wrong.sha256records "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 secondhttptestTLS server, on another host. That server sees noAuthorizationheader. 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.muheld) 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:
geoMirrortrackswantSince(when the pointer first named a sha that isn't loaded) andpointerExpired. It also readsfeed.json(capped, best effort) on each poll, keepingfeedFailingandfeedBox.func (m *geoMirror) AlertCode(now time.Time, box string) float64implements the priority above.func (e *Edge) GeoIPAlertCode(now time.Time) float64uses 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: fillobs.GeoIPfrom a published source, the same pattern asgeoIPSource;internal/host/agent.go: publishe.GeoIPAlertCode;internal/host/alertsink.go: thegeoipcases.
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.Evaluatefires 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.GeoIPis 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.