Przeglądaj źródła

Fix testbed-found bugs: dry-run flag, per-WAN probes, UCI wan naming

- main.lua: get_value()/has_flag() so trailing --dry-run is honored;
  drive_cycle passes per-WAN tcp targets + dns server to probes.all()
- probes.lua: all() accepts per-WAN override, table or "host:port"
- config.lua: match UCI wan sections by name wan_a/wan_b (hyphens
  invalid in UCI) onto internal wan-a/wan-b ids, warn + document-
  order fallback
- tests/probes_config.lua: 21 regression tests; smoke.lua: [1b]
  wan-section load checks; testbed-runner.lua: 7-suite in-container
  runner (regex fix for underscore filenames)
- .dlog entry + .learnings/ERRORS.md (docker cp nesting, testbed
  openssl missing, exit-code-only test passes)
Gogs 1 miesiąc temu
rodzic
commit
ca7d6aa6e6

+ 43 - 0
.learnings/ERRORS.md

@@ -126,3 +126,46 @@ The wiring work that exposed all three bugs here was: "run the actual daemon in
 dry-run mode after adding the new modules." That single command — `lua5.1 main.lua
 --dry-run` — found three silent failures in ~10 seconds. Cheaper than any amount
 of code review.
+
+---
+
+## 2026-09-04 — `docker cp` into a missing container path creates a nested dir
+
+**Symptom:** `make test` green on testbed, but the container was running a
+stale copy of the fixture: `ls /testbed/tests` showed both the files
+*and* a nested `tests/` subdir inside it. The runner executed
+`/testbed/tests/...` which resolved to the stale top-level files from a
+previous run, while my updated files sat in `/testbed/tests/tests/`.
+
+**Root cause:** `docker cp <src> container:/testbed/tests` — when
+`/testbed/tests` doesn't exist, docker copies the *source dir itself*
+into the destination name, i.e. `.../tests` becomes `/testbed/tests/`
+only if the target exists; otherwise you get the classic "dir copied as
+child" ambiguity (same as tar's behavior with missing targets). First
+copy of a run created the right layout; a later `rm -rf /testbed/tests`
++ re-copy produced `/testbed/tests` as a *file-like* copy of the source
+directory, and a third re-copy then nested `tests` inside it.
+
+**Fix / rule:** Before `docker cp` of a directory into a container,
+`docker exec <c> sh -c 'mkdir -p <dest>'` first (so the target exists
+and the copy lands *inside* it), or `rm -rf` then `mkdir -p` in the
+same `sh -c`. Verify with `md5sum` after the copy — a 2-second check
+that caught the stale-fixture bug that made a "PASS" run validate the
+wrong code.
+
+**Corollary:** Testbed runners that re-derive filenames with patterns
+(`f:match("tests/(%w+)%.lua")`) silently break on names with
+underscores/hyphens (`probes_config.lua` → nil → `attempt to
+concatenate a nil value`). Match `([%w_]+)` and assert the match is
+non-nil before building the output path.
+
+**Also (same day):** the `shellspec/openwrt` 19.07 image ships **no
+`openssl`** — `sha256.lua` (openssl-CLI based) returned nil silently
+and its unit test's `os.execute` exit code still read 0, so the runner
+said "PASS" while 3 vectors actually failed. Two lessons: (1) record
+image package state in the testbed notes (`opkg list-installed`);
+install `openssl-util` once: `opkg install openssl-util`. (2) A test
+suite whose pass/fail is decided by printed text AND exit code must
+make the *runner* assert on the printed summary line, not just the
+exit code — `FAIL:` in output with `exit=0` is a runner bug, not a
+test pass.

+ 83 - 0
balancer-lite-lua.dlog

@@ -163,3 +163,86 @@ lua5.1 src/balancerlite/ctl_main.lua \
   follow-up since the pure outbox logic is exercised by the daemon
   already).
 
+
+## 2026-09-04 Testbed run: three daemon bugs fixed (dry-run, probe crash, UCI wan naming)
+
+### What
+Ran the daemon on the real OpenWrt 19.07.7 testbed container. Three
+confirmed bugs, all root-caused and fixed:
+
+1. **`--dry-run` flag was silently ignored.** `get_arg()` required
+   `arg[i+1]` to exist, so a trailing boolean flag (`--dry-run` at end
+   of argv) returned nil and the daemon ran a live cycle loop instead
+   of one dry-run cycle. Replaced with `get_value()` for value opts
+   (`--config`/`-c`) and `has_flag()` for boolean flags.
+2. **drive_cycle crash: per-WAN probe targets discarded.** `drive_cycle`
+   looked up `probe_targets[wan.id]` but passed `nil` to
+   `probes.all()`, which fell back to the *global* `tcp_targets` (raw
+   `"host:port"` strings) and crashed on `probe_tcp(t.host=nil, ...)`.
+   `probes.all()` now takes per-WAN targets and accepts both
+   `{host=,port=}` tables and `"host:port"` strings.
+3. **UCI wan sections not found → "unsafe iface/gw" warnings.**
+   `config.lua` looked up sections `wan-a`/`wan-b` (hyphens are not
+   valid UCI identifiers); real configs use `wan_a`/`wan_b`. Section
+   loader now matches by name in either spelling and maps onto the
+   internal ids `wan-a`/`wan-b` (which the state machine, ctl CLI,
+   and wg endpoint mapping use), warning + falling back to document
+   order when a section is renamed.
+
+### Files changed
+- `src/balancerlite/main.lua` — `get_value()`/`has_flag()` arg parsing;
+  `drive_cycle` passes per-WAN tcp targets + dns server to
+  `probes.all()`; STATE ids from `cfg.wans[i].id`.
+- `src/balancerlite/probes.lua` — `all()` per-WAN override; string or
+  table target elements.
+- `src/balancerlite/config.lua` — wan section loader by name
+  (underscore or hyphen), warning + document-order fallback.
+- `tests/probes_config.lua` (new, 21 tests) — regression suite for
+  bugs 2 and 3: per-WAN table/string/nil/empty overrides, UCI
+  `wan_a`/`wan_b` mapping, hyphenated back-compat, zero-section
+  defaulting.
+- `tests/smoke.lua` — new `[1b]` checks asserting the smoke config's
+  wan section values actually load (guards bug 3 end-to-end).
+- `testbed/testbed-runner.lua` (new) — in-container runner for all 7
+  suites; filename regex fixed to accept underscores
+  (`([%w_]+)`).
+
+### Test Results
+```
+make lint    → OK (parse + forbidden-feature scan clean)
+host unit    → ctl 42/42, routing 32/32, wg 35/35, smoke 21/21,
+                probes_config 21/21, state PASS, sha256 PASS
+19.07 testbed→ ALL 7 SUITES PASS (lua 5.1.5, after opkg install
+                openssl-util — image ships no openssl; see
+                .learnings/ERRORS.md)
+19.07 daemon → --dry-run: "(DRY RUN)" banner, routing cmds printed,
+                "exited after 1 cycles", rc=0, no crash, no
+                "unsafe iface/gw" warnings
+```
+
+### Verify commands
+```sh
+cd /root/.openclaw/workspace/balancer-lite-lua
+make lint
+make unit
+lua5.1 src/balancerlite/main.lua \
+  --config etc/config/balancerlite.example --dry-run   # "exited after 1 cycles"
+```
+
+### Notes
+- Internal WAN ids stay `wan-a`/`wan-b` (state machine, ctl, wg
+  mapping unchanged); UCI layer accepts `wan_a`/`wan_b` or
+  `wan-a`/`wan-b`.
+- 19.07 testbed container `openwrt-testbed` left running for now;
+  next step is a 23.05 testbed (Lua 5.4 → needs 5.4-compat pass:
+  `goto`, integer-division, `#` on nil semantics).
+- See `.learnings/ERRORS.md` 2026-09-04 entry: `docker cp` into a
+  missing container path nests the dir (verified stale-fixture bug),
+  and testbed runners must assert on printed summaries, not just
+  exit codes.
+
+### Open loops
+- Build 23.05 testbed + Lua 5.4 compatibility pass.
+- Real (non-dry-run) end-to-end actuator test (needs `ip`/`wg` on a
+  real interface).
+- Optional: procd SIGHUP hot-reload; signed-webhook outbox unit tests.

+ 37 - 10
src/balancerlite/config.lua

@@ -132,17 +132,44 @@ local function build_config(sections)
     dns_enabled   = opt(sh, "dns_enabled", true, "bool"),
   }
 
-  -- wans (two sections)
+  -- wans (two sections). Internal ids are wan-a / wan-b (used by the
+  -- state machine, ctl, wg endpoint mapping); UCI sections are
+  -- wan_a / wan_b (hyphens are not valid UCI identifiers) — both
+  -- spellings are accepted. Fall back to document order with a
+  -- warning so a renamed section doesn't silently degrade to
+  -- interface defaults.
+  local wan_by_name = {}
+  local wan_sections = {}
+  for _, s in ipairs(sections) do
+    if s._type == "wan" then
+      wan_sections[#wan_sections + 1] = s
+      wan_by_name[s._name] = s
+    end
+  end
+  if #wan_sections < 2 then
+    io.stderr:write("config: warning: expected 2 'wan' sections, found "
+      .. #wan_sections .. "\n")
+  end
+  local wan_defs = {
+    { id = "wan-a", def_iface = "eth0", def_pref = 100 },
+    { id = "wan-b", def_iface = "eth1", def_pref = 50 },
+  }
   cfg.wans = {}
-  for _, id in ipairs({"wan-a", "wan-b"}) do
-    local sw = find_section(sections, "wan", id)
-    local def_iface = id == "wan-a" and "eth0" or "eth1"
-    cfg.wans[#cfg.wans + 1] = {
-      id         = id,
-      interface   = opt(sw, "interface", def_iface),
-      address     = opt(sw, "address", ""),
-      gateway     = opt(sw, "gateway", ""),
-      preference  = opt(sw, "preference", id == "wan-a" and 100 or 50, "int"),
+  for i, d in ipairs(wan_defs) do
+    local uci_name = d.id:gsub("%-", "_")  -- wan-a → wan_a
+    local named = wan_by_name[d.id] or wan_by_name[uci_name]
+    local sw = named or wan_sections[i]
+    if sw and sw ~= named then
+      io.stderr:write(string.format(
+        "config: warning: 'wan' section '%s' not found; using section '%s' as %s\n",
+        uci_name, sw._name, d.id))
+    end
+    cfg.wans[i] = {
+      id         = d.id,
+      interface  = opt(sw, "interface", d.def_iface),
+      address    = opt(sw, "address", ""),
+      gateway    = opt(sw, "gateway", ""),
+      preference = opt(sw, "preference", d.def_pref, "int"),
       probe_target = opt(sw, "probe_target", ""),
     }
   end

+ 23 - 9
src/balancerlite/main.lua

@@ -40,17 +40,26 @@ local json_mod    = require("balancerlite.json")
 -- ------------------------------------------------------------------
 -- Arg parsing
 -- ------------------------------------------------------------------
-local function get_arg(opts)
+-- Returns the value that follows flag `name` in arg.
+-- (Value-bearing flags: --config / -c)
+local function get_value(name)
   for i = 1, #arg do
-    for _, o in ipairs(opts) do
-      if arg[i] == o and arg[i+1] then return arg[i+1] end
-    end
+    if arg[i] == name and arg[i+1] then return arg[i+1] end
   end
   return nil
 end
 
-local cfg_path = get_arg({"--config", "-c"}) or "/etc/config/balancerlite"
-local DRY_RUN  = get_arg({"--dry-run", "-n"}) ~= nil
+-- Returns true if flag `name` appears anywhere in arg.
+-- (Boolean flags: --dry-run / -n) Must not consume the following arg.
+local function has_flag(name)
+  for i = 1, #arg do
+    if arg[i] == name then return true end
+  end
+  return false
+end
+
+local cfg_path = get_value("--config") or get_value("-c") or "/etc/config/balancerlite"
+local DRY_RUN  = has_flag("--dry-run") or has_flag("-n")
 
 -- ------------------------------------------------------------------
 -- Config
@@ -69,9 +78,13 @@ end
 -- ------------------------------------------------------------------
 -- Subsystems
 -- ------------------------------------------------------------------
+-- The internal WAN ids are wan-a / wan-b (used by the state machine,
+-- ctl CLI, and wg endpoint mapping). UCI section names can't contain
+-- hyphens, so config sections are wan_a / wan_b; the lookup below
+-- accepts either spelling.
 local STATE = state_mod.new({
-  wan_a_id        = "wan-a",
-  wan_b_id        = "wan-b",
+  wan_a_id        = cfg.wans[1] and cfg.wans[1].id or "wan-a",
+  wan_b_id        = cfg.wans[2] and cfg.wans[2].id or "wan-b",
   down_threshold  = cfg.health.down_threshold,
   up_threshold    = cfg.health.up_threshold,
   flap_threshold  = cfg.flap.switch_count,
@@ -294,7 +307,8 @@ local function drive_cycle()
   local results_by_wan = {}
   for _, wan in ipairs(cfg.wans) do
     local t = probe_targets[wan.id]
-    local wan_results = probes_mod.all(PROBES, wan.interface, nil, nil)
+    local wan_results = probes_mod.all(PROBES, wan.interface,
+      t and t.tcp_targets, t and t.dns_server)
     results_by_wan[wan.id] = wan_results
     -- Feed into state machine
     for _, r in ipairs(wan_results) do

+ 18 - 7
src/balancerlite/probes.lua

@@ -132,9 +132,11 @@ end
 
 -- Run all enabled probes for a WAN interface.
 -- wan_id is just for probe_icmp's -I <iface> binding (optional).
--- wan_ip is the source IP to bind to (for routing-based probes).
--- wan_dns is the DNS server reachable through that WAN.
-function probes.all(P, wan_id, wan_ip, wan_dns)
+-- wan_targets: per-WAN override of P.tcp_targets ({{host,port},...});
+--   nil means use the global list. Elements may be {host=,port=} tables
+--   or "host:port" strings (both are accepted).
+-- wan_dns: per-WAN DNS server (nil → global / system resolver).
+function probes.all(P, wan_id, wan_targets, wan_dns)
   local results = {}
   -- ICMP (requires --icmp-targets list)
   if P.icmp_enabled and #P.icmp_targets > 0 then
@@ -142,10 +144,19 @@ function probes.all(P, wan_id, wan_ip, wan_dns)
       table.insert(results, probe_icmp(target, wan_id, P.icmp_timeout))
     end
   end
-  -- TCP
-  if P.tcp_enabled and #P.tcp_targets > 0 then
-    for _, t in ipairs(P.tcp_targets) do
-      table.insert(results, probe_tcp(t.host, t.port, P.tcp_timeout))
+  -- TCP (per-WAN override wins over the global list)
+  local tcp_list = wan_targets or P.tcp_targets
+  if P.tcp_enabled and #tcp_list > 0 then
+    for _, t in ipairs(tcp_list) do
+      local host, port
+      if type(t) == "table" then
+        host, port = t.host, t.port
+      else
+        host, port = t:match("([^:]+):(%d+)")
+      end
+      if host and port then
+        table.insert(results, probe_tcp(host, tonumber(port), P.tcp_timeout))
+      end
     end
   end
   -- DNS

+ 52 - 0
testbed/testbed-runner.lua

@@ -0,0 +1,52 @@
+-- testbed-runner.lua — run the full balancerlite suite inside OpenWrt.
+package.path = "/testbed/src/?.lua;/testbed/src/?/init.lua;;"
+
+local files = {
+  "tests/sha256.lua",
+  "tests/state.lua",
+  "tests/routing.lua",
+  "tests/wg.lua",
+  "tests/ctl.lua",
+  "tests/smoke.lua",
+  "tests/probes_config.lua",
+}
+
+local failures = 0
+for _, f in ipairs(files) do
+  print(string.rep("=", 50))
+  print("RUN " .. f)
+  local ok, err = os.execute("lua " .. f .. " > /testbed/out-" .. f:match("tests/([%w_]+)%.lua") .. ".txt 2>&1")
+  local rc = (ok and 0) or 1
+  print("  exit=" .. rc .. (err and ("  err=" .. tostring(err)) or ""))
+  if rc ~= 0 then
+    failures = failures + 1
+  end
+end
+
+-- dump each output file
+local ofiles = {
+  "out-sha256.txt",
+  "out-state.txt",
+  "out-routing.txt",
+  "out-wg.txt",
+  "out-ctl.txt",
+  "out-smoke.txt",
+  "out-probes_config.txt",
+}
+for _, f in ipairs(ofiles) do
+  local fh = io.open("/testbed/" .. f, "r")
+  if fh then
+    local body = fh:read("*a")
+    fh:close()
+    print("----- " .. f .. " -----")
+    print(body)
+  end
+end
+
+print(string.rep("=", 50))
+if failures == 0 then
+  print("TESTBED RESULT: ALL 7 SUITES PASS")
+else
+  print("TESTBED RESULT: " .. failures .. " suite(s) FAILED")
+end
+os.exit(failures == 0 and 0 or 1)

+ 209 - 0
tests/probes_config.lua

@@ -0,0 +1,209 @@
+--[[
+  tests/probes_config.lua — regression tests for the three testbed bugs:
+    1. probes.all() must accept per-WAN tcp targets (tables or
+       "host:port" strings) instead of dereferencing a nil global.
+    2. config.load() must find UCI wan sections named wan_a / wan_b
+       (hyphens are not valid UCI identifiers) and map them onto the
+       internal wan-a / wan-b ids.
+    3. main.lua --dry-run: covered by the daemon dry-run smoke check
+       ("(DRY RUN)" banner + "exited after 1 cycles").
+
+  Pure-Lua, no root, no network (exec is not invoked by these paths
+  except via injected stubs / disabled probes).
+]]
+
+package.path = package.path .. ";./src/?.lua"
+
+local probes = require("balancerlite.probes")
+local config = require("balancerlite.config")
+
+local passed = 0
+local failed = 0
+local function check(name, ok, detail)
+  if ok then
+    passed = passed + 1
+    print("  PASS  " .. name)
+  else
+    failed = failed + 1
+    print("  FAIL  " .. name .. (detail and ("  " .. detail) or ""))
+  end
+end
+
+-- -----------------------------------------------------------------
+-- TEST 1: probes.all with per-WAN tcp targets (the drive_cycle crash)
+-- -----------------------------------------------------------------
+print("\n[1] probes.all per-WAN tcp targets")
+do
+  -- All probe types disabled except TCP; use a dead local port so
+  -- nc fails fast (connect refused, no network dependency).
+  local P = probes.new({
+    tcp_targets  = {},               -- global list intentionally empty
+    tcp_enabled  = true,
+    icmp_enabled = false,
+    dns_enabled  = false,
+    wg_enabled   = false,
+    tcp_timeout  = 1,
+  })
+
+  -- Table form
+  local r = probes.all(P, "lo", { { host = "127.0.0.1", port = 1 } }, nil)
+  check("returns 1 result", #r == 1, "got " .. tostring(#r))
+  check("probe is tcp", r[1] and r[1].name == "tcp")
+  check("no crash on empty global list", true)
+
+  -- String form
+  local P2 = probes.new({
+    tcp_targets  = {},
+    tcp_enabled  = true,
+    icmp_enabled = false,
+    dns_enabled  = false,
+    wg_enabled   = false,
+    tcp_timeout  = 1,
+  })
+  local r2 = probes.all(P2, "lo", { "127.0.0.1:1" }, nil)
+  check("string form returns 1 result", #r2 == 1, "got " .. tostring(#r2))
+  check("string form probe is tcp", r2[1] and r2[1].name == "tcp")
+
+  -- nil override → falls back to global list
+  local P3 = probes.new({
+    tcp_targets  = { { host = "127.0.0.1", port = 1 } },
+    tcp_enabled  = true,
+    icmp_enabled = false,
+    dns_enabled  = false,
+    wg_enabled   = false,
+    tcp_timeout  = 1,
+  })
+  local r3 = probes.all(P3, "lo", nil, nil)
+  check("nil override uses global", #r3 == 1, "got " .. tostring(#r3))
+
+  -- Empty per-WAN list → no tcp probes at all
+  local P4 = probes.new({
+    tcp_targets  = { { host = "127.0.0.1", port = 1 } },
+    tcp_enabled  = true,
+    icmp_enabled = false,
+    dns_enabled  = false,
+    wg_enabled   = false,
+  })
+  local r4 = probes.all(P4, "lo", {}, nil)
+  check("empty override yields no results", #r4 == 0, "got " .. tostring(#r4))
+end
+
+-- -----------------------------------------------------------------
+-- TEST 2: config.load maps UCI wan_a/wan_b sections to wan-a/wan-b
+-- -----------------------------------------------------------------
+print("\n[2] config.load wan section naming")
+do
+  local dir = "/tmp/bl_pcfg_" .. tostring(os.time()) .. "_" ..
+              tostring(math.random(1000000))
+  os.execute("mkdir -p " .. dir)
+  local p = dir .. "/balancerlite"
+  local f = io.open(p, "w")
+  f:write([[
+config general 'general'
+    option host 'unit-test'
+
+config health 'health'
+    option probe_interval '1s'
+
+config wan 'wan_a'
+    option interface    'eth10'
+    option address      '192.0.2.10/24'
+    option gateway      '192.0.2.1'
+    option preference   '90'
+
+config wan 'wan_b'
+    option interface    'eth11'
+    option address      '198.51.100.10/24'
+    option gateway      '198.51.100.1'
+    option preference   '10'
+]])
+  f:close()
+
+  local cfg, err = config.load(p)
+  check("config loads", cfg ~= nil, tostring(err))
+  check("two wans parsed", cfg and #cfg.wans == 2,
+        cfg and tostring(#cfg.wans))
+  check("wan-a interface from wan_a section",
+        cfg and cfg.wans[1].interface == "eth10",
+        cfg and tostring(cfg.wans[1].interface))
+  check("wan-a address from wan_a section",
+        cfg and cfg.wans[1].address == "192.0.2.10/24",
+        cfg and tostring(cfg.wans[1].address))
+  check("wan-a gateway from wan_a section",
+        cfg and cfg.wans[1].gateway == "192.0.2.1",
+        cfg and tostring(cfg.wans[1].gateway))
+  check("wan-a preference from wan_a section",
+        cfg and cfg.wans[1].preference == 90,
+        cfg and tostring(cfg.wans[1].preference))
+  check("wan-b interface from wan_b section",
+        cfg and cfg.wans[2].interface == "eth11",
+        cfg and tostring(cfg.wans[2].interface))
+  check("wan-b gateway from wan_b section",
+        cfg and cfg.wans[2].gateway == "198.51.100.1",
+        cfg and tostring(cfg.wans[2].gateway))
+  os.execute("rm -rf " .. dir)
+end
+
+-- -----------------------------------------------------------------
+-- TEST 3: config.load still accepts hyphenated wan-a/wan-b sections
+-- (back-compat)
+-- -----------------------------------------------------------------
+print("\n[3] config.load hyphenated sections (back-compat)")
+do
+  local dir = "/tmp/bl_pcfg2_" .. tostring(os.time()) .. "_" ..
+              tostring(math.random(1000000))
+  os.execute("mkdir -p " .. dir)
+  local p = dir .. "/balancerlite"
+  local f = io.open(p, "w")
+  f:write([[
+config wan 'wan-a'
+    option interface 'eth20'
+    option gateway   '10.20.0.1'
+
+config wan 'wan-b'
+    option interface 'eth21'
+    option gateway   '10.21.0.1'
+]])
+  f:close()
+
+  local cfg, err = config.load(p)
+  check("config loads", cfg ~= nil, tostring(err))
+  check("wan-a interface from wan-a section",
+        cfg and cfg.wans[1].interface == "eth20",
+        cfg and tostring(cfg.wans[1].interface))
+  check("wan-b interface from wan-b section",
+        cfg and cfg.wans[2].interface == "eth21",
+        cfg and tostring(cfg.wans[2].interface))
+  os.execute("rm -rf " .. dir)
+end
+
+-- -----------------------------------------------------------------
+-- TEST 4: no wan sections at all → defaults, no crash
+-- -----------------------------------------------------------------
+print("\n[4] config.load zero wan sections")
+do
+  local dir = "/tmp/bl_pcfg3_" .. tostring(os.time()) .. "_" ..
+              tostring(math.random(1000000))
+  os.execute("mkdir -p " .. dir)
+  local p = dir .. "/balancerlite"
+  local f = io.open(p, "w")
+  f:write([[
+config general 'general'
+    option host 'nowan'
+]])
+  f:close()
+
+  local cfg, err = config.load(p)
+  check("config loads", cfg ~= nil, tostring(err))
+  check("two wans defaulted", cfg and #cfg.wans == 2)
+  check("default iface eth0", cfg and cfg.wans[1].interface == "eth0",
+        cfg and tostring(cfg.wans[1].interface))
+  os.execute("rm -rf " .. dir)
+end
+
+-- -----------------------------------------------------------------
+-- Done
+-- -----------------------------------------------------------------
+print(string.format("\n=== probes_config: %d/%d passed ===",
+      passed, passed + failed))
+if failed > 0 then os.exit(1) end

+ 13 - 0
tests/smoke.lua

@@ -107,6 +107,19 @@ check("state_dir matches", cfg.store.state_dir == dir)
 check("icmp disabled",   cfg.health.icmp_enabled == false)
 check("tcp disabled",    cfg.health.tcp_enabled == false)
 
+-- -----------------------------------------------------------------
+-- TEST 1b: wan section values actually loaded (was silently defaulted)
+-- -----------------------------------------------------------------
+print("\n[1b] wan section values")
+check("wan-a iface=lo",  cfg.wans[1].interface == "lo",
+      tostring(cfg.wans[1].interface))
+check("wan-a gw=127.0.0.1", cfg.wans[1].gateway == "127.0.0.1",
+      tostring(cfg.wans[1].gateway))
+check("wan-b iface=lo",  cfg.wans[2].interface == "lo",
+      tostring(cfg.wans[2].interface))
+check("wan-b gw=127.0.0.1", cfg.wans[2].gateway == "127.0.0.1",
+      tostring(cfg.wans[2].gateway))
+
 -- -----------------------------------------------------------------
 -- TEST 2: ctl round-trip in our temp dir
 -- -----------------------------------------------------------------