From af43698847c8ec6724b0ab3019eb038d7fc3dbfa Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Fri, 18 Sep 2026 21:32:03 +0100 Subject: [PATCH] 20260918 - Drop blah2 tracker config and let it reach deployed nodes blah2's built-in tracker has been removed, so process.tracker and network.ports.track now configure code that does not exist. Removing them from default.yml alone reaches no node. The merger seeds user.yml with a whole copy of default.yml on first boot and user.yml overrides default.yml, so every node on the estate persists the block that shipped when it booted. That is exactly how 46f7bc2 came to sit unshipped: it set process.tracker.enable: false on 2026-07-24, released in v0.4.2.0 and every tag since, and reached zero nodes in nearly two months, so every node kept running the tracker and paying for its unbounded nInactive growth. migrate_remove_tracker() is unconditional, unlike migrate_doppler_span() and migrate_tracker_forward(), which compare against a LEGACY_* value so a deliberate choice survives. There is no deliberate choice to protect once the code the keys configured is gone, and whole-block equality would skip exactly the nodes whose M/N someone had tuned. Safe to ship alongside the blah2 change: config-merger has restart "no" and blah2 depends on it completing, so the merged config is regenerated from the baked-in default.yml before blah2 may start, on every compose up. Mender does that on rollback as well, where the old merger restores the old keys for the old binary, and the migration never writes user.yml to disk. The cost is that hand-swapping a pre-removal blah2 image onto an updated node will now abort on the missing key, since ryml's default error handler calls abort(). Verified end to end against fairforest-b's real user.yml: the merged config loses both keys and keeps fc, location, node_id, tracker_forward, ADS-B truth and the detection and clutter settings. Also drops the dead "3003:3003" mapping from the staging compose. Co-Authored-By: Claude Opus 5 (1M context) --- config-merger/script/merge_config.py | 50 +++++++++++++++ config-merger/test/test_merge_config.py | 81 +++++++++++++++++++++++++ config/default.yml | 15 +---- staging/docker-compose.staging.yml | 1 - 4 files changed, 132 insertions(+), 15 deletions(-) diff --git a/config-merger/script/merge_config.py b/config-merger/script/merge_config.py index 69ae48d..588413c 100755 --- a/config-merger/script/merge_config.py +++ b/config-merger/script/merge_config.py @@ -245,6 +245,55 @@ def migrate_tracker_forward(user): del network['tracker_forward'] +def migrate_remove_tracker(user): + """Drop blah2's built-in tracker settings, which no longer exist. + + blah2's own tracker was removed outright: nothing consumed it (retina-gui's + Tracker page reads the retina-tracker sidecar, and blah2-api's /api/tracker + is gone with it), and it carried an unbounded leak - Track::remove() never + erased nInactive, which Tracker::process() then deep copied every CPI, worth + roughly 3 MB/h on a board with no swap. + + default.yml no longer ships these keys, but that alone reaches no node. The + merger seeds user.yml with a whole copy of default.yml on first boot and + user.yml overrides default.yml, so every node already on the estate persists + the block that shipped when it booted. Same first-boot copy problem + migrate_doppler_span() and migrate_tracker_forward() solve. + + Unconditional, unlike the migrations above, which compare against a LEGACY_* + value so a deliberate choice survives. There is no deliberate choice to + protect here: the code these keys configured does not exist, so any value is + equally dead. network.ports.track goes the same way, since the socket blah2 + published tracks on and the listener blah2-api read them with are both gone. + + Dropping the keys rather than rewriting them leaves user.yml on disk + untouched, so this stays load-bearing rather than a one-shot fixup, and a + Mender rollback regenerates the old config from the old default.yml. + + Note for anyone bisecting: once this has run, a blah2 image from before the + removal will abort on startup rather than warn, because it reads + process.tracker.enable through ryml, whose default error handler calls + abort(). Swap the config-merger image back at the same time. + """ + try: + process = user['process'] + except (KeyError, TypeError): + process = None + if isinstance(process, dict) and 'tracker' in process: + print("Dropping first-boot process.tracker from user config: blah2's " + "built-in tracker no longer exists") + del process['tracker'] + + try: + ports = user['network']['ports'] + except (KeyError, TypeError): + return + if isinstance(ports, dict) and 'track' in ports: + print("Dropping first-boot network.ports.track from user config: " + "nothing publishes or listens on it any more") + del ports['track'] + + def ensure_node_id(user_config_path): """Add/update node_id in user config from Mender device identity""" try: @@ -343,6 +392,7 @@ def main(): print("Applying user overrides...") migrate_doppler_span(user) migrate_tracker_forward(user) + migrate_remove_tracker(user) merge(config, user) else: print("User config is empty, using defaults") diff --git a/config-merger/test/test_merge_config.py b/config-merger/test/test_merge_config.py index 1ed65aa..1c1036d 100755 --- a/config-merger/test/test_merge_config.py +++ b/config-merger/test/test_merge_config.py @@ -831,6 +831,87 @@ def test_tracker_forward_absent_from_user_config(self): self.assertTrue(forward['enabled']) self.assertEqual(forward['port'], 30100) + # --- blah2 tracker removal -------------------------------------------- + # The same first-boot-copy problem again, but unconditional: the code these + # keys configured no longer exists, so there is no deliberate value to keep. + + def write_tracker_configs(self, user_process=None, user_ports=None, + forced_config=None): + """Write a default/user/forced set where default.yml has no tracker.""" + self.write_yaml(os.path.join(self.defaults_dir, 'default.yml'), { + 'process': {'detection': {'enable': True}}, + 'network': {'ip': '0.0.0.0', 'ports': {'api': 3000, 'map': 3001}}, + }) + self.write_yaml(os.path.join(self.defaults_dir, 'forced.yml'), forced_config or {}) + user = {} + if user_process is not None: + user['process'] = user_process + if user_ports is not None: + user['network'] = {'ports': user_ports} + self.write_yaml(os.path.join(self.config_dir, 'user.yml'), user) + + def test_tracker_block_removed_from_merged_config(self): + """The first-boot copy of process.tracker does not survive the merge""" + self.write_tracker_configs(user_process={ + 'tracker': {'enable': True, 'initiate': {'M': 3, 'N': 5, 'maxAcc': 10}, + 'delete': 10, 'smooth': 'none'}}) + + process = self.read_yaml(self.run_merge())['process'] + + self.assertNotIn('tracker', process) + + def test_track_port_removed_from_merged_config(self): + """network.ports.track goes too: nothing publishes or listens on it""" + self.write_tracker_configs(user_ports={'api': 3000, 'track': 3003}) + + ports = self.read_yaml(self.run_merge())['network']['ports'] + + self.assertNotIn('track', ports) + self.assertEqual(ports['api'], 3000) + + def test_tracker_removal_is_unconditional(self): + """Tuned M/N is not a reason to keep settings for code that is gone""" + self.write_tracker_configs(user_process={ + 'tracker': {'enable': True, 'initiate': {'M': 4, 'N': 9}, 'delete': 25}}) + + process = self.read_yaml(self.run_merge())['process'] + + self.assertNotIn('tracker', process) + + def test_tracker_removal_leaves_the_rest_of_process_alone(self): + """Only the tracker block goes; the user's other process keys stay""" + self.write_tracker_configs(user_process={ + 'tracker': {'enable': True}, + 'detection': {'enable': False, 'pfa': 1e-6}, + }) + + process = self.read_yaml(self.run_merge())['process'] + + self.assertNotIn('tracker', process) + self.assertFalse(process['detection']['enable']) + self.assertEqual(process['detection']['pfa'], 1e-6) + + def test_tracker_absent_from_user_config(self): + """A user.yml with no tracker block merges without complaint""" + self.write_tracker_configs(user_process={'detection': {'enable': True}}) + + config = self.read_yaml(self.run_merge()) + + self.assertNotIn('tracker', config['process']) + self.assertNotIn('track', config['network']['ports']) + + def test_tracker_removal_does_not_rewrite_user_yml(self): + """The overlay on disk is untouched, so the removal has to stay in place""" + self.write_tracker_configs( + user_process={'tracker': {'enable': True}}, + user_ports={'api': 3000, 'track': 3003}) + + self.run_merge() + + user = self.read_yaml(os.path.join(self.config_dir, 'user.yml')) + self.assertTrue(user['process']['tracker']['enable']) + self.assertEqual(user['network']['ports']['track'], 3003) + if __name__ == '__main__': unittest.main() diff --git a/config/default.yml b/config/default.yml index 2ff9448..1efdbe1 100644 --- a/config/default.yml +++ b/config/default.yml @@ -36,18 +36,6 @@ process: minDelay: 5 minDoppler: 15 nCentroid: 6 - tracker: - # blah2's own built-in tracker - superseded by the retina-tracker sidecar - # (see retina_tracker below), which is what's actually used. Left disabled - # so nothing runs its associate/promote loop or feeds its unused - # blah2-api /api/tracker endpoint. - enable: false - initiate: - M: 3 - N: 5 - maxAcc: 10 - delete: 10 - smooth: "none" network: ip: 0.0.0.0 @@ -55,7 +43,6 @@ network: api: 3000 map: 3001 detection: 3002 - track: 3003 timestamp: 4000 timing: 4001 iqdata: 4002 @@ -130,7 +117,7 @@ tar1090: adsblol_radius: 40 # retina-tracker sidecar (github.com/offworldlabs/retina-tracker) config. -# Unrelated to process.tracker above, which is blah2's own built-in tracker. +# blah2 has no built-in tracker of its own; this sidecar does all tracking. retina_tracker: # dB. Detections below this are discarded before tracking even begins - # too high and the tracker silently never confirms a track no matter how diff --git a/staging/docker-compose.staging.yml b/staging/docker-compose.staging.yml index 42f97ad..d19f36b 100644 --- a/staging/docker-compose.staging.yml +++ b/staging/docker-compose.staging.yml @@ -56,7 +56,6 @@ services: - "3000:3000" # API port - "3001:3001" # Map port - "3002:3002" # Detection port - - "3003:3003" # Track port networks: - blah2 - caddy