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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
6e3968c19a
commit
cd26553d21
+65
-4
@@ -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()
|
||||
|
||||
@@ -14,6 +14,7 @@ class HealthCheckTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.ping_ok = True
|
||||
self.ip_link_ok = True
|
||||
self.sigsegvs = 0
|
||||
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
self.uci_state = {}
|
||||
@@ -32,6 +33,11 @@ class HealthCheckTest(unittest.TestCase):
|
||||
return (1, '', '')
|
||||
if a[0] == 'logread':
|
||||
return (0, 'SIGSEGV\n' * self.sigsegs if hasattr(self, 'sigsegs') else '', '')
|
||||
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[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci_state[k] = v
|
||||
|
||||
+68
-1
@@ -162,10 +162,56 @@ class FakeSock:
|
||||
pass
|
||||
|
||||
|
||||
class ReconHopperTest(unittest.TestCase):
|
||||
def test_preflight_verifies_every_non_dfs_channel(self):
|
||||
calls = []
|
||||
with mock.patch.object(
|
||||
server, '_set_monitor_channel',
|
||||
side_effect=lambda interface, channel:
|
||||
calls.append((interface, channel)) or (True, '')):
|
||||
self.assertEqual(
|
||||
server._recon_hopper_preflight(),
|
||||
(True, 'monitor channel control ready'))
|
||||
expected = [
|
||||
(interface, channel)
|
||||
for interface, channels in server.RECON_CHANNELS.items()
|
||||
for channel in channels
|
||||
]
|
||||
self.assertEqual(calls, expected)
|
||||
|
||||
def test_preflight_stops_at_first_unusable_channel(self):
|
||||
def set_channel(interface, channel):
|
||||
if interface == 'wlan1mon' and channel == 44:
|
||||
return False, 'wlan1mon channel 44: busy'
|
||||
return True, ''
|
||||
|
||||
with mock.patch.object(
|
||||
server, '_set_monitor_channel', side_effect=set_channel):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('wlan1mon channel 44', detail)
|
||||
|
||||
def test_set_channel_surfaces_iw_failure(self):
|
||||
with mock.patch.object(
|
||||
server, 'device_run',
|
||||
return_value=(240, '', 'Device or resource busy')):
|
||||
ok, detail = server._set_monitor_channel('wlan0mon', 6)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('wlan0mon channel 6', detail)
|
||||
self.assertIn('Device or resource busy', detail)
|
||||
|
||||
|
||||
class DaemonSockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# h_recon_start now reads shared scan state; keep these isolated.
|
||||
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
||||
preflight = mock.patch.object(
|
||||
server, '_recon_hopper_preflight', return_value=(True, 'ready'))
|
||||
start = mock.patch.object(server, '_start_recon_hopper')
|
||||
preflight.start()
|
||||
start.start()
|
||||
self.addCleanup(preflight.stop)
|
||||
self.addCleanup(start.stop)
|
||||
|
||||
def test_socket_call_posts_json_to_sock(self):
|
||||
server.DAEMON_SOCK = '/tmp/api.sock'
|
||||
@@ -201,6 +247,7 @@ class DaemonSockTest(unittest.TestCase):
|
||||
status, data = server.h_recon_start(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60}))
|
||||
server._start_recon_hopper.assert_called_once_with(60)
|
||||
|
||||
def test_start_defaults_empty_body(self):
|
||||
calls = []
|
||||
@@ -228,6 +275,18 @@ class DaemonSockTest(unittest.TestCase):
|
||||
self.assertEqual(data['daemon'], {'error': 'no radio'})
|
||||
self.assertFalse(server._recon_scan_state['active'])
|
||||
|
||||
def test_start_reports_hopper_preflight_failure(self):
|
||||
calls = []
|
||||
server._recon_hopper_preflight.return_value = (
|
||||
False, 'wlan1mon channel 36: Device or resource busy')
|
||||
server.daemon_sock_call = lambda *args, **kwargs: calls.append(args)
|
||||
status, data = server.h_recon_start(
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 503)
|
||||
self.assertEqual(data['error'], 'recon radio preflight failed')
|
||||
self.assertIn('wlan1mon', data['detail'])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class ReconScanStateTest(unittest.TestCase):
|
||||
"""The webui mirrors the duration of the Pager's native timed scan."""
|
||||
@@ -236,6 +295,13 @@ class ReconScanStateTest(unittest.TestCase):
|
||||
self.db = make_db()
|
||||
server.RECON_DB = self.db
|
||||
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
||||
preflight = mock.patch.object(
|
||||
server, '_recon_hopper_preflight', return_value=(True, 'ready'))
|
||||
start = mock.patch.object(server, '_start_recon_hopper')
|
||||
preflight.start()
|
||||
start.start()
|
||||
self.addCleanup(preflight.stop)
|
||||
self.addCleanup(start.stop)
|
||||
|
||||
def tearDown(self):
|
||||
os.unlink(self.db)
|
||||
@@ -368,12 +434,13 @@ class ReconExtrasTest(unittest.TestCase):
|
||||
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertFalse(data['hopper_online'])
|
||||
self.assertIn('hopper_error', data)
|
||||
self.assertTrue(data['history_reset'])
|
||||
|
||||
def test_hopper_online_cached(self):
|
||||
server._hopper_cache.update({'updated': 0, 'online': None})
|
||||
with mock.patch.object(server, 'wifi_ifaces',
|
||||
return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']):
|
||||
return_value=['wlan0mon', 'wlan1mon']):
|
||||
self.assertTrue(server._hopper_online())
|
||||
# Second call within the cache window must not re-run iwinfo.
|
||||
with mock.patch.object(server, 'wifi_ifaces',
|
||||
|
||||
Reference in New Issue
Block a user