From 40950017404c71094965d4cf43cb30c5caa1fd89 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 12 Mar 2026 08:19:09 +0000 Subject: [PATCH] fix: implement 24h hot-reload for GeoIP databases and update tests (#15) --- docs/install/overview.md | 4 +- spec/unit/decision_api_spec.lua | 43 +++++++++++++++++ spec/unit/features/decision_api.feature | 6 +++ src/fairvisor/decision_api.lua | 63 +++++++++++++++++-------- 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/docs/install/overview.md b/docs/install/overview.md index 937a14d..12a7745 100644 --- a/docs/install/overview.md +++ b/docs/install/overview.md @@ -35,7 +35,7 @@ Mount the directory containing the `.mmdb` files to `/etc/geoip2` in the Fairvis docker run -v /path/to/geoip-dbs:/etc/geoip2 ... ghcr.io/fairvisor/fairvisor-edge ``` -The runtime will automatically detect the databases and reload them every 24 hours. +The runtime will automatically detect the databases on startup and reload them every 24 hours. ### 3. Verification -If the databases are missing, Nginx will fail to start. Ensure the worker process has read permissions for these files. +Fairvisor uses a Lua-based reader for MMDB files. If the databases are missing or unreadable, a warning will be logged in OpenResty's error log, and GeoIP/ASN lookups will gracefully fall back to headers or return `nil`. diff --git a/spec/unit/decision_api_spec.lua b/spec/unit/decision_api_spec.lua index 9510bd2..c322134 100644 --- a/spec/unit/decision_api_spec.lua +++ b/spec/unit/decision_api_spec.lua @@ -114,6 +114,14 @@ runner:given("^the decision api dependencies are initialized$", function(ctx) ngx.log_calls[#ngx.log_calls + 1] = { level = level, args = { ... } } end + ngx.timer_calls = {} + ngx.timer = { + every = function(interval, callback) + ngx.timer_calls[#ngx.timer_calls + 1] = { interval = interval, callback = callback } + return true + end + } + ctx.bundle = { id = "bundle-1" } ctx.bundle_loader = { @@ -146,6 +154,17 @@ runner:given("^the decision api dependencies are initialized$", function(ctx) ctx.env_overrides = {} ctx.original_getenv = os.getenv + + -- Mock GeoIP databases for reload test + ctx.test_cleanup_files = ctx.test_cleanup_files or {} + os.execute("mkdir -p data/geoip2") + local country_db = "data/geoip2/GeoLite2-Country.mmdb" + local f = io.open(country_db, "wb") + if f then + f:write("dummy") + f:close() + ctx.test_cleanup_files[#ctx.test_cleanup_files + 1] = country_db + end end) runner:given('^the mode is "([^"]+)"$', function(ctx, mode) @@ -164,6 +183,7 @@ runner:given('^the mode is "([^"]+)"$', function(ctx, mode) assert.is_true(ok) assert.is_nil(err) + ctx.decision_api_initted = true end) runner:given('^the mode is "([^"]+)" and retry jitter is "([^"]+)"$', function(ctx, mode, _jitter) @@ -179,6 +199,7 @@ runner:given('^the mode is "([^"]+)" and retry jitter is "([^"]+)"$', function(c }) assert.is_true(ok) assert.is_nil(err) + ctx.decision_api_initted = true end) runner:given('^headers include "([^"]+)" as "([^"]+)"$', function(_, name, value) @@ -377,7 +398,29 @@ runner:then_('^request context body_hash is nil$', function(ctx) assert.is_nil(ctx.request_context.body_hash) end) +runner:then_("^geoip hot%-reload timer is scheduled for 24 hours$", function(_) + local found = false + local intervals = {} + for _, t in ipairs(ngx.timer_calls or {}) do + intervals[#intervals + 1] = t.interval + if t.interval == 86400 then + found = true + break + end + end + assert.is_true(found, "GeoIP reload timer (86400) not found in: " .. table.concat(intervals, ", ")) +end) + runner:when("^I build request context$", function(ctx) + -- Ensure init is called with default mocks if not already done via "the mode is..." + if not ctx.decision_api_initted then + decision_api.init({ + bundle_loader = ctx.bundle_loader, + rule_engine = ctx.rule_engine, + health = ctx.health, + }) + ctx.decision_api_initted = true + end ctx.request_context = decision_api.build_request_context() end) diff --git a/spec/unit/features/decision_api.feature b/spec/unit/features/decision_api.feature index eac2ddb..186c2e9 100644 --- a/spec/unit/features/decision_api.feature +++ b/spec/unit/features/decision_api.feature @@ -109,6 +109,12 @@ Feature: Decision API unit behavior And request context body_hash is nil And the test cleanup restores globals + Scenario: BUG-15 geoip hot-reload timer is scheduled + Given the decision api dependencies are initialized + When I build request context + Then geoip hot-reload timer is scheduled for 24 hours + And the test cleanup restores globals + Rule: Access phase decision mapping Scenario: Returns 503 when no bundle exists Given the decision api dependencies are initialized diff --git a/src/fairvisor/decision_api.lua b/src/fairvisor/decision_api.lua index 3b904b7..c480894 100644 --- a/src/fairvisor/decision_api.lua +++ b/src/fairvisor/decision_api.lua @@ -785,6 +785,41 @@ local function _inject_debug_headers(headers, decision) return headers end +local function _reload_geoip(geoip) + if not geoip then return end + + local country_paths = { "/etc/geoip2/GeoLite2-Country.mmdb", "data/geoip2/GeoLite2-Country.mmdb" } + local asn_paths = { "/etc/geoip2/GeoLite2-ASN.mmdb", "data/geoip2/GeoLite2-ASN.mmdb" } + + local dbs = {} + for _, path in ipairs(country_paths) do + local f = io.open(path, "rb") + if f then + f:close() + dbs.country = path + break + end + end + + for _, path in ipairs(asn_paths) do + local f = io.open(path, "rb") + if f then + f:close() + dbs.asn = path + break + end + end + + if dbs.country or dbs.asn then + _log_info("geoip_init_attempting databases_present") + local ok, err = pcall(geoip.init, dbs) + if not ok then + _log_err("geoip_init_failed err=", err) + else + _log_info("geoip_databases_loaded country=", tostring(dbs.country ~= nil), " asn=", tostring(dbs.asn ~= nil)) + end + end +end function _M.init(deps) if type(deps) ~= "table" then @@ -811,26 +846,16 @@ function _M.init(deps) local geoip_ok, geoip = pcall(require, "resty.maxminddb") if geoip_ok then _deps.geoip = geoip - local country_db = "/etc/geoip2/GeoLite2-Country.mmdb" - local asn_db = "/etc/geoip2/GeoLite2-ASN.mmdb" - - local dbs = {} - local f_country = io.open(country_db, "rb") - if f_country then - f_country:close() - dbs.country = country_db - end - - local f_asn = io.open(asn_db, "rb") - if f_asn then - f_asn:close() - dbs.asn = asn_db - end - - if dbs.country or dbs.asn then - local ok, err = pcall(geoip.init, dbs) + _reload_geoip(geoip) + + -- Hot-reload GeoIP databases every 24 hours as required by issue #15 + if ngx.timer and ngx.timer.every then + local ok, err = ngx.timer.every(86400, function(premature) + if premature then return end + _reload_geoip(geoip) + end) if not ok then - _log_err("geoip_init_failed err=", err) + _log_err("geoip_reload_timer_failed err=", err) end end end