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()