fix: non-disruptive startup checks + clean service lifecycle (v1.3.1)

- Env check is read-only when state is sane: no pineapd command-socket
  writes, no live pool-list commits, no wifi reload; pineapd restarts only
  when a runtime-sensitive UCI value changed or the daemon was down
- Failed monitor repairs now fail the startup contract instead of being
  reported as fixed; runtime pool state is read from active config
- Enterprise AP recovery runs only on device boot (PAGER_WEBUI_BOOT), not
  on every web-service restart
- serve() gates the HTTP port on startup checks with bounded retries and
  shuts down cleanly on SIGTERM/SIGINT; the recon watchdog waits
  interruptibly
- Recon uses a bounded userspace channel scheduler that drives both
  monitor radios over non-DFS channels, with preflight verification,
  serialized starts, and per-cycle error reporting
- payload.sh waits for real readiness on start, fully removes the boot
  service (stop + disable + delete) on stop, and surfaces a
  stopped-but-enabled boot service; deploy.sh refreshes the installed
  init script even when the service is stopped
- Bump version to 1.3.1

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-19 12:03:11 -05:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6e3968c19a
commit cd26553d21
8 changed files with 451 additions and 68 deletions
+65 -4
View File
@@ -28,6 +28,7 @@ class EnvCheckTest(unittest.TestCase):
self.runs = []
self.ping_ok = True
self.daemon_ok = True
self.ip_link_ok = True
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
self.uci_state = {}
server.ENV_CHECK_STATE.update({'report': None, 'overall': None, 'updated': 0,
@@ -56,6 +57,8 @@ class EnvCheckTest(unittest.TestCase):
def fake_run(self, args, timeout=20, input_data=None):
self.runs.append(list(args))
a = list(args)
if a[0] == 'pidof' and a[1] == 'pineapple':
return (0, '23456\n', '') if self.daemon_ok else (1, '', '')
if a[0] == 'pidof' and a[1] == 'pineapd':
return (0, '12345\n', '') if self.ping_ok else (1, '', '')
if a[0] == 'uci':
@@ -78,6 +81,11 @@ class EnvCheckTest(unittest.TestCase):
if k.startswith(sec + '.')), '')
if a[0] == '_pineap':
return (0, '', '')
if a[:3] == ['ip', 'link', 'set']:
if self.ip_link_ok:
self.iface_up[a[3]] = True
return (0, '', '')
return (1, '', 'interface unavailable')
if a[0] in ('ip', '/etc/init.d/pineapd'):
return (0, '', '')
return (0, '', '')
@@ -96,11 +104,15 @@ class EnvCheckTest(unittest.TestCase):
self.assertEqual([r['ok'] for r in report],
['pass'] * len(report))
self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled')
self.assertIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs)
self.assertNotIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs)
def test_applies_sane_defaults_when_missing(self):
report = server.env_check()
self.assertEqual(self.steps(report, 'sane-off UCI defaults applied')[0]['ok'], 'fixed')
self.assertEqual(
self.steps(report, 'runtime safety settings changed')[0]['ok'],
'fixed')
self.assertIn(['/etc/init.d/pineapd', 'restart'], self.runs)
for key, value in server.PINEAPD_SAFE_UCI.items():
self.assertEqual(self.uci_state[key], value)
@@ -109,12 +121,13 @@ class EnvCheckTest(unittest.TestCase):
report = server.env_check()
self.assertEqual(self.steps(report, 'sane-off UCI defaults already set')[0]['ok'], 'pass')
def test_clears_refilled_pool_list(self):
def test_does_not_commit_refilled_pool_while_live(self):
self.safe_set()
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
report = server.env_check()
actions = ' | '.join((r.get('action') or '') for r in report)
self.assertIn('pool-list cleared', actions)
self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state)
self.assertNotIn('pool-list cleared', actions)
self.assertIn('pineapd.@ssidpool[0].ssid', self.uci_state)
def test_restarts_pineapd_when_down(self):
self.safe_set()
@@ -154,6 +167,15 @@ class EnvCheckTest(unittest.TestCase):
self.assertEqual(self.steps(report, 'monitor interfaces brought up')[0]['ok'], 'fixed')
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], self.runs)
def test_unavailable_monitor_fails_startup_contract(self):
self.safe_set()
self.iface_up = {'wlan0mon': False, 'wlan1mon': True}
self.ip_link_ok = False
report = server.env_check()
step = self.steps(report, 'monitor interfaces unavailable')[0]
self.assertEqual(step['ok'], 'fail')
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
def test_monitors_up_pass(self):
self.safe_set()
report = server.env_check()
@@ -240,6 +262,45 @@ class EnvCheckTest(unittest.TestCase):
self.assertEqual(code, 1)
self.assertIn('[FAIL]', buf.getvalue())
def test_startup_check_retries_core_failure(self):
reports = [
[{'ok': 'fail', 'detail': 'daemon unreachable'}],
[{'ok': 'pass', 'detail': 'daemon reachable'}],
]
old_check = server.env_check
old_sleep = server.time.sleep
def check():
report = reports.pop(0)
server.ENV_CHECK_STATE['overall'] = report[0]['ok']
return report
server.env_check = check
server.time.sleep = lambda seconds: None
try:
result = server.startup_env_check(attempts=2, delay=0)
finally:
server.env_check = old_check
server.time.sleep = old_sleep
self.assertEqual(result[0]['ok'], 'pass')
def test_startup_check_raises_after_retries(self):
old_check = server.env_check
old_sleep = server.time.sleep
def check():
server.ENV_CHECK_STATE['overall'] = 'fail'
return [{'ok': 'fail', 'detail': 'daemon unreachable'}]
server.env_check = check
server.time.sleep = lambda seconds: None
try:
with self.assertRaises(RuntimeError):
server.startup_env_check(attempts=2, delay=0)
finally:
server.env_check = old_check
server.time.sleep = old_sleep
def test_health_exposes_env_and_pool_runtime(self):
self.safe_set()
server.env_check()