From 26db1846f91fa86dd6fbdfd9fe45cac4b17dd34f Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Sun, 13 Sep 2026 17:22:12 +0100 Subject: [PATCH] 20260913 - Let the new tracker_forward default reach deployed nodes Detections reach the tracker from blah2_api now rather than retina-gui, but the node that changed default.yml could not move a node that had already booted. The merger seeds user.yml from default.yml on first boot, so every node on the estate persists the block that shipped at the time and overrides whatever we ship later. Same problem migrate_doppler_span() solves. Drop the block when it is exactly the one we used to ship, so the shipped default applies again. Only the whole legacy triple matches: a node aimed at some other host or port made a real choice and is left alone. Deleting the keys rather than rewriting them means a later change of host or port also lands, and user.yml on disk is untouched, so this stays load-bearing rather than becoming a one-shot fixup. Narrower than the Doppler case. 'blah2_tracker' is a compose service name and these nodes run blah2-api with network_mode host, where it resolves to nothing, so a node deliberately pointed there was already not forwarding. Co-Authored-By: Claude Opus 5 (1M context) --- config-merger/script/merge_config.py | 48 ++++++++++ config-merger/test/test_merge_config.py | 114 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/config-merger/script/merge_config.py b/config-merger/script/merge_config.py index 322bad0..69ae48d 100755 --- a/config-merger/script/merge_config.py +++ b/config-merger/script/merge_config.py @@ -38,6 +38,15 @@ LEGACY_DOPPLER_MIN = -200 LEGACY_DOPPLER_MAX = 200 +# The tracker_forward block default.yml shipped while retina-gui still did the +# forwarding itself. A user.yml holding exactly this is the merger's own +# first-boot copy rather than anyone's choice. See migrate_tracker_forward(). +LEGACY_TRACKER_FORWARD = { + 'enabled': False, + 'host': 'blah2_tracker', + 'port': 3012, +} + def get_node_id_from_mender(): """Read node_id from Mender device identity file (generated by mender-device-identity)""" @@ -198,6 +207,44 @@ def migrate_doppler_span(user): del ambiguity['dopplerMax'] +def migrate_tracker_forward(user): + """Drop a tracker_forward the user never chose, so default.yml can change it. + + Detections reach the tracker one way or the other: retina-gui used to forward + them, and now blah2-api does, over the socket named here. The handover has to + happen on every node at once, because the tracker's ingest accepts a single + connection and whichever producer holds it locks the other out. + + The first-boot copy problem is the same one migrate_doppler_span() solves: the + merger seeds user.yml from default.yml, so every node already on the estate + persists the block that shipped when it booted, and a change to default.yml + could never reach it. + + Narrower than the Doppler case, because the legacy value is not something a + node could sensibly be set to. 'blah2_tracker' is a compose service name, and + these nodes run blah2-api with network_mode host where it resolves to nothing, + so a node deliberately pointed there is already not forwarding. Only the whole + legacy triple matches: a node aimed at some other host or port is a real + choice and is left alone. + + Dropping the keys rather than rewriting them means whatever default.yml ships + applies, including a later change of host or port. user.yml on disk is + untouched, so this stays load-bearing rather than a one-shot fixup. + """ + try: + network = user['network'] + except (KeyError, TypeError): + return + + if not isinstance(network, dict): + return + + if network.get('tracker_forward') == LEGACY_TRACKER_FORWARD: + print("Dropping first-boot tracker_forward (disabled, blah2_tracker:3012) " + "from user config so the shipped default applies") + del network['tracker_forward'] + + def ensure_node_id(user_config_path): """Add/update node_id in user config from Mender device identity""" try: @@ -295,6 +342,7 @@ def main(): if user: # Only merge if user.yml has actual content print("Applying user overrides...") migrate_doppler_span(user) + migrate_tracker_forward(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 f0d0aba..1ed65aa 100755 --- a/config-merger/test/test_merge_config.py +++ b/config-merger/test/test_merge_config.py @@ -717,6 +717,120 @@ def test_doppler_span_absent_from_user_config(self): self.assertEqual(output['process']['ambiguity']['dopplerMin'], -300) self.assertEqual(output['process']['ambiguity']['dopplerMax'], 300) + # --- tracker_forward migration ---------------------------------------- + # Same first-boot-copy problem as the Doppler span: every node persists the + # block that shipped when it booted, so default.yml alone cannot move it. + + def default_with_forward(self, enabled=True, host='127.0.0.1', port=30100): + """A default.yml carrying the shipped tracker_forward block.""" + return { + 'network': { + 'ip': '0.0.0.0', + 'ports': {'api': 3000}, + 'tracker_forward': {'enabled': enabled, 'host': host, 'port': port}, + } + } + + def write_forward_configs(self, user_network, forced_config=None): + """Write a default/user/forced set differing only in the network block.""" + self.write_yaml(os.path.join(self.defaults_dir, 'default.yml'), self.default_with_forward()) + self.write_yaml(os.path.join(self.defaults_dir, 'forced.yml'), forced_config or {}) + self.write_yaml(os.path.join(self.config_dir, 'user.yml'), {'network': user_network}) + + def test_tracker_forward_first_boot_copy_migrated(self): + """The block that shipped with gui-side forwarding gives way to the default""" + self.write_forward_configs( + {'node_id': 'test-node', + 'tracker_forward': {'enabled': False, 'host': 'blah2_tracker', 'port': 3012}}) + + forward = self.read_yaml(self.run_merge())['network']['tracker_forward'] + + self.assertTrue(forward['enabled']) + self.assertEqual(forward['host'], '127.0.0.1') + self.assertEqual(forward['port'], 30100) + + def test_tracker_forward_migration_leaves_the_rest_of_network_alone(self): + """Only the forwarding block moves; everything else is still the user's""" + self.write_forward_configs( + {'node_id': 'ret7dd2cb0d', 'ip': '10.0.0.1', + 'tracker_forward': {'enabled': False, 'host': 'blah2_tracker', 'port': 3012}}) + + network = self.read_yaml(self.run_merge())['network'] + + self.assertEqual(network['node_id'], 'ret7dd2cb0d') + self.assertEqual(network['ip'], '10.0.0.1') + + def test_tracker_forward_deliberate_target_kept(self): + """A node aimed somewhere real is a choice, not a first-boot copy""" + self.write_forward_configs( + {'tracker_forward': {'enabled': True, 'host': '192.168.0.9', 'port': 9999}}) + + forward = self.read_yaml(self.run_merge())['network']['tracker_forward'] + + self.assertEqual(forward['host'], '192.168.0.9') + self.assertEqual(forward['port'], 9999) + + def test_tracker_forward_deliberately_disabled_elsewhere_kept(self): + """Disabled is only a fossil alongside the rest of the legacy triple""" + self.write_forward_configs( + {'tracker_forward': {'enabled': False, 'host': '127.0.0.1', 'port': 30100}}) + + forward = self.read_yaml(self.run_merge())['network']['tracker_forward'] + + self.assertFalse(forward['enabled']) + + def test_tracker_forward_partial_legacy_match_kept(self): + """The legacy host on a different port is not the block we shipped""" + self.write_forward_configs( + {'tracker_forward': {'enabled': False, 'host': 'blah2_tracker', 'port': 3013}}) + + forward = self.read_yaml(self.run_merge())['network']['tracker_forward'] + + self.assertFalse(forward['enabled']) + self.assertEqual(forward['port'], 3013) + + def test_tracker_forward_extra_key_is_not_the_shipped_block(self): + """Anything added by hand makes it someone's config rather than a copy""" + self.write_forward_configs( + {'tracker_forward': {'enabled': False, 'host': 'blah2_tracker', + 'port': 3012, 'note': 'left off on purpose'}}) + + forward = self.read_yaml(self.run_merge())['network']['tracker_forward'] + + self.assertFalse(forward['enabled']) + self.assertEqual(forward['note'], 'left off on purpose') + + def test_tracker_forward_forced_still_wins(self): + """forced.yml keeps the last word over a migrated block""" + self.write_forward_configs( + {'tracker_forward': {'enabled': False, 'host': 'blah2_tracker', 'port': 3012}}, + forced_config={'network': {'tracker_forward': {'enabled': False}}}, + ) + + forward = self.read_yaml(self.run_merge())['network']['tracker_forward'] + + self.assertFalse(forward['enabled']) + + def test_tracker_forward_migration_does_not_rewrite_user_yml(self): + """The overlay on disk is untouched, so the migration has to stay in place""" + self.write_forward_configs( + {'tracker_forward': {'enabled': False, 'host': 'blah2_tracker', 'port': 3012}}) + + self.run_merge() + + user = self.read_yaml(os.path.join(self.config_dir, 'user.yml')) + self.assertEqual(user['network']['tracker_forward'], + {'enabled': False, 'host': 'blah2_tracker', 'port': 3012}) + + def test_tracker_forward_absent_from_user_config(self): + """A user.yml with no forwarding block just takes the default""" + self.write_forward_configs({'node_id': 'test-node'}) + + forward = self.read_yaml(self.run_merge())['network']['tracker_forward'] + + self.assertTrue(forward['enabled']) + self.assertEqual(forward['port'], 30100) + if __name__ == '__main__': unittest.main()