diff --git a/charms/sunbeam-machine/config.yaml b/charms/sunbeam-machine/config.yaml index 86de094b..f8008c6e 100644 --- a/charms/sunbeam-machine/config.yaml +++ b/charms/sunbeam-machine/config.yaml @@ -6,4 +6,12 @@ options: debug: default: False type: boolean - + http_proxy: + description: Set HTTP_PROXY in /etc/environment + type: string + https_proxy: + description: Set HTTPS_PROXY in /etc/environment + type: string + no_proxy: + description: Set NO_PROXY in /etc/environment + type: string diff --git a/charms/sunbeam-machine/src/charm.py b/charms/sunbeam-machine/src/charm.py index a46e8ab4..8de402fc 100755 --- a/charms/sunbeam-machine/src/charm.py +++ b/charms/sunbeam-machine/src/charm.py @@ -33,6 +33,7 @@ from ops.main import ( main, ) +ETC_ENVIRONMENT = "/etc/environment" logger = logging.getLogger(__name__) @@ -41,6 +42,7 @@ class SunbeamMachineCharm(sunbeam_charm.OSBaseOperatorCharm): _state = ops.framework.StoredState() service_name = "sunbeam-machine" + proxy_configs = ["http_proxy", "https_proxy", "no_proxy"] def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) @@ -64,6 +66,34 @@ class SunbeamMachineCharm(sunbeam_charm.OSBaseOperatorCharm): logger.error("Error executing sysctl", exc_info=True) raise sunbeam_guard.BlockedExceptionError("Sysctl command failed") + def _on_config_changed(self, event: ops.ConfigChangedEvent): + self.configure_charm(event) + with open(ETC_ENVIRONMENT, mode="r", encoding="utf-8") as file: + current_env = dict( + line.strip().split("=", 1) for line in file if "=" in line + ) + logger.info(f"Existing content of /etc/environment: {current_env}") + + proxy = {p: v for p in self.proxy_configs if (v := self.config.get(p))} + if all( + proxy.get(p) == current_env.get(p.upper()) + for p in self.proxy_configs + ): + return + + # Remove proxies not set + not_set_proxies = self.proxy_configs - proxy.keys() + for p in not_set_proxies: + if (p_upper := p.upper()) in current_env: + del current_env[p_upper] + + # Capitalise proxy keys and update env + proxy_in_caps = {k.upper(): v for k, v in proxy.items()} + current_env.update(proxy_in_caps) + + with open(ETC_ENVIRONMENT, mode="w", encoding="utf-8") as file: + file.write("\n".join([f"{k}={v}" for k, v in current_env.items()])) + def _on_remove(self, event: ops.RemoveEvent): self.sysctl.remove() diff --git a/charms/sunbeam-machine/tests/unit/test_charm.py b/charms/sunbeam-machine/tests/unit/test_charm.py index 468fdf95..7679240d 100644 --- a/charms/sunbeam-machine/tests/unit/test_charm.py +++ b/charms/sunbeam-machine/tests/unit/test_charm.py @@ -14,6 +14,11 @@ """Tests for Sunbeam Machine charm.""" +from unittest.mock import ( + mock_open, + patch, +) + import charm import ops_sunbeam.test_utils as test_utils @@ -30,7 +35,7 @@ class _SunbeamMachineCharm(charm.SunbeamMachineCharm): class TestCharm(test_utils.CharmTestCase): """Classes for testing Sunbeam Machine charm.""" - PATCHES = [] + PATCHES = ["sysctl"] def setUp(self): """Setup Sunbeam machine tests.""" @@ -46,5 +51,108 @@ class TestCharm(test_utils.CharmTestCase): def test_initial(self): """Bootstrap test initial.""" - self.harness.begin_with_initial_hooks() + file_content_dict = {"PATH": "FAKEPATH"} + env_file_content = "\n".join( + f"{k}={v}" for k, v in file_content_dict.items() + ) + + with patch( + "builtins.open", new_callable=mock_open, read_data=env_file_content + ) as mock_file: + self.harness.begin_with_initial_hooks() + mock_file().write.assert_not_called() + self.assertTrue(self.harness.charm.bootstrapped()) + + def test_proxy_settings(self): + """Test setting proxies.""" + # test_data is a tuple of /etc/environment file content as dict, proxy config as dict, + # expected content as dict + # As the below tests are run in loop as subtests, they act as juju config commands. + # Means the configs set in the previous test data remains until it is reset by + # specifying config as empty string. + test_data = [ + # Case 1: No proxy in environment file, set http_proxy, https_proxy + ( + {"PATH": "FAKEPATH"}, + { + "http_proxy": "http://proxyserver:3128", + "https_proxy": "http://proxyserver:3128", + }, + { + "PATH": "FAKEPATH", + "HTTP_PROXY": "http://proxyserver:3128", + "HTTPS_PROXY": "http://proxyserver:3128", + }, + ), + # Case 2: Add no_proxy to above configuration + ( + { + "PATH": "FAKEPATH", + "HTTP_PROXY": "http://proxyserver:3128", + "HTTPS_PROXY": "http://proxyserver:3128", + }, + {"no_proxy": "localhost,127.0.0.1"}, + { + "PATH": "FAKEPATH", + "HTTP_PROXY": "http://proxyserver:3128", + "HTTPS_PROXY": "http://proxyserver:3128", + "NO_PROXY": "localhost,127.0.0.1", + }, + ), + # Case 3: Update http proxy to different value + ( + { + "PATH": "FAKEPATH", + "HTTP_PROXY": "http://proxyserver:3128", + "HTTPS_PROXY": "http://proxyserver:3128", + "NO_PROXY": "localhost,127.0.0.1", + }, + { + "http_proxy": "http://proxyserver:3120", + "https_proxy": "http://proxyserver:3128", + }, + { + "PATH": "FAKEPATH", + "HTTP_PROXY": "http://proxyserver:3120", + "HTTPS_PROXY": "http://proxyserver:3128", + "NO_PROXY": "localhost,127.0.0.1", + }, + ), + # Case 4: Reset the no_proxy config + ( + { + "PATH": "FAKEPATH", + "HTTP_PROXY": "http://proxyserver:3120", + "HTTPS_PROXY": "http://proxyserver:3128", + "NO_PROXY": "localhost,127.0.0.1", + }, + {"no_proxy": ""}, + { + "PATH": "FAKEPATH", + "HTTP_PROXY": "http://proxyserver:3120", + "HTTPS_PROXY": "http://proxyserver:3128", + }, + ), + ] + + with patch( + "builtins.open", new_callable=mock_open, read_data="" + ) as mock_file: + self.harness.begin_with_initial_hooks() + + for index, d in enumerate(test_data): + with self.subTest(msg=f"test_proxy_settings-{index}", data=d): + env_file_content = "\n".join( + f"{k}={v}" for k, v in d[0].items() + ) + expected_content = "\n".join( + f"{k}={v}" for k, v in d[2].items() + ) + with patch( + "builtins.open", + new_callable=mock_open, + read_data=env_file_content, + ) as mock_file: + self.harness.update_config(d[1]) + mock_file().write.assert_called_with(expected_content)