From d8e07fd356c9c3f2a1014ca8c18e0993a8fcf8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Zamora=20Mart=C3=ADnez?= <76525382+zmraul@users.noreply.github.com> Date: Wed, 26 Jul 2023 16:04:45 +0200 Subject: [PATCH] Sysctl library (#99) --- lib/charms/operator_libs_linux/v0/sysctl.py | 288 +++++++++++++++++- tests/integration/test_sysctl.py | 59 ++++ tests/unit/test_sysctl.py | 313 ++++++++++++++++++++ 3 files changed, 644 insertions(+), 16 deletions(-) create mode 100644 tests/integration/test_sysctl.py create mode 100644 tests/unit/test_sysctl.py diff --git a/lib/charms/operator_libs_linux/v0/sysctl.py b/lib/charms/operator_libs_linux/v0/sysctl.py index c6d3daa..96fcea9 100644 --- a/lib/charms/operator_libs_linux/v0/sysctl.py +++ b/lib/charms/operator_libs_linux/v0/sysctl.py @@ -1,24 +1,81 @@ -"""TODO: Add a proper docstring here. +# Copyright 2023 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. -This is a placeholder docstring for this charm library. Docstrings are -presented on Charmhub and updated whenever you push a new version of the -library. +"""Handler for the sysctl config. -Complete documentation about creating and documenting libraries can be found -in the SDK docs at https://juju.is/docs/sdk/libraries. +This library allows your charm to create and configure sysctl options to the machine. -See `charmcraft publish-lib` and `charmcraft fetch-lib` for details of how to -share and consume charm libraries. They serve to enhance collaboration -between charmers. Use a charmer's libraries for classes that handle -integration with their charm. +Validation and merge capabilities are added, for situations where more than one application +are setting values. The following files can be created: -Bear in mind that new revisions of the different major API versions (v0, v1, -v2 etc) are maintained independently. You can continue to update v0 and v1 -after you have pushed v3. +- /etc/sysctl.d/90-juju- + Requirements from one application requesting to configure the values. -Markdown is supported, following the CommonMark specification. +- /etc/sysctl.d/95-juju-sysctl.conf + Merged file resulting from all other `90-juju-*` application files. + + +A charm using the sysctl lib will need a data structure like the following: +``` +{ +"vm.swappiness": "1", +"vm.max_map_count": "262144", +"vm.dirty_ratio": "80", +"vm.dirty_background_ratio": "5", +"net.ipv4.tcp_max_syn_backlog": "4096", +} +``` + +Now, it can use that template within the charm, or just declare the values directly: + +```python +from charms.operator_libs_linux.v0 import sysctl + +class MyCharm(CharmBase): + + def __init__(self, *args): + ... + self.sysctl = sysctl.Config(self.meta.name) + + self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.on.remove, self._on_remove) + + def _on_install(self, _): + # Altenatively, read the values from a template + sysctl_data = {"net.ipv4.tcp_max_syn_backlog": "4096"}} + + try: + self.sysctl.configure(config=sysctl_data) + except (sysctl.SysctlPermissionError, sysctl.ValidationError) as e: + logger.error(f"Error setting values on sysctl: {e.message}") + self.unit.status = BlockedStatus("Sysctl config not possible") + except sysctl.SysctlError: + logger.error("Error on sysctl") + + def _on_remove(self, _): + self.sysctl.remove() +``` """ +import logging +import re +from pathlib import Path +from subprocess import STDOUT, CalledProcessError, check_output +from typing import Dict, List + +logger = logging.getLogger(__name__) + # The unique Charmhub library identifier, never change it LIBID = "17a6cd4d80104d15b10f9c2420ab3266" @@ -27,6 +84,205 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 1 +LIBPATCH = 2 + +CHARM_FILENAME_PREFIX = "90-juju-" +SYSCTL_DIRECTORY = Path("/etc/sysctl.d") +SYSCTL_FILENAME = SYSCTL_DIRECTORY / "95-juju-sysctl.conf" +SYSCTL_HEADER = f"""# This config file was produced by sysctl lib v{LIBAPI}.{LIBPATCH} +# +# This file represents the output of the sysctl lib, which can combine multiple +# configurations into a single file like. +""" + + +class Error(Exception): + """Base class of most errors raised by this library.""" + + @property + def message(self): + """Return the message passed as an argument.""" + return self.args[0] + + +class CommandError(Error): + """Raised when there's an error running sysctl command.""" + + +class ApplyError(Error): + """Raised when there's an error applying values in sysctl.""" + + +class ValidationError(Error): + """Exception representing value validation error.""" + + +class Config(Dict): + """Represents the state of the config that a charm wants to enforce.""" + + _apply_re = re.compile(r"sysctl: permission denied on key \"([a-z_\.]+)\", ignoring$") + + def __init__(self, name: str) -> None: + self.name = name + self._data = self._load_data() + + def __contains__(self, key: str) -> bool: + """Check if key is in config.""" + return key in self._data + + def __len__(self): + """Get size of config.""" + return len(self._data) + + def __iter__(self): + """Iterate over config.""" + return iter(self._data) + + def __getitem__(self, key: str) -> str: + """Get value for key form config.""" + return self._data[key] + + @property + def charm_filepath(self) -> Path: + """Name for resulting charm config file.""" + return SYSCTL_DIRECTORY / f"{CHARM_FILENAME_PREFIX}{self.name}" + + def configure(self, config: Dict[str, str]) -> None: + """Configure sysctl options with a desired set of params. + + Args: + config: dictionary with keys to configure: + ``` + {"vm.swappiness": "10", ...} + ``` + """ + self._parse_config(config) + + # NOTE: case where own charm calls configure() more than once. + if self.charm_filepath.exists(): + self._merge(add_own_charm=False) + + conflict = self._validate() + if conflict: + raise ValidationError(f"Validation error for keys: {conflict}") + + snapshot = self._create_snapshot() + logger.debug("Created snapshot for keys: %s", snapshot) + try: + self._apply() + except ApplyError: + self._restore_snapshot(snapshot) + raise + + self._create_charm_file() + self._merge() + + def remove(self) -> None: + """Remove config for charm. + + The removal process won't apply any sysctl configuration. It will only merge files from + remaining charms. + """ + self.charm_filepath.unlink(missing_ok=True) + logger.info("Charm config file %s was removed", self.charm_filepath) + self._merge() + + def _validate(self) -> List[str]: + """Validate the desired config params against merged ones.""" + common_keys = set(self._data.keys()) & set(self._desired_config.keys()) + conflict_keys = [] + for key in common_keys: + if self._data[key] != self._desired_config[key]: + logger.warning( + "Values for key '%s' are different: %s != %s", + key, + self._data[key], + self._desired_config[key], + ) + conflict_keys.append(key) + + return conflict_keys + + def _create_charm_file(self) -> None: + """Write the charm file.""" + with open(self.charm_filepath, "w") as f: + f.write(f"# {self.name}\n") + for key, value in self._desired_config.items(): + f.write(f"{key}={value}\n") + + def _merge(self, add_own_charm=True) -> None: + """Create the merged sysctl file. + + Args: + add_own_charm : bool, if false it will skip the charm file from the merge. + """ + # get all files that start by 90-juju- + data = [SYSCTL_HEADER] + paths = set(SYSCTL_DIRECTORY.glob(f"{CHARM_FILENAME_PREFIX}*")) + if not add_own_charm: + paths.discard(self.charm_filepath.as_posix()) + + for path in paths: + with open(path, "r") as f: + data += f.readlines() + with open(SYSCTL_FILENAME, "w") as f: + f.writelines(data) + + # Reload data with newly created file. + self._data = self._load_data() + + def _apply(self) -> None: + """Apply values to machine.""" + cmd = [f"{key}={value}" for key, value in self._desired_config.items()] + result = self._sysctl(cmd) + failed_values = [ + self._apply_re.match(line) for line in result if self._apply_re.match(line) + ] + logger.debug("Failed values: %s", failed_values) + + if failed_values: + msg = f"Unable to set params: {[f.group(1) for f in failed_values]}" + logger.error(msg) + raise ApplyError(msg) + + def _create_snapshot(self) -> Dict[str, str]: + """Create a snapshot of config options that are going to be set.""" + cmd = ["-n"] + list(self._desired_config.keys()) + values = self._sysctl(cmd) + return dict(zip(list(self._desired_config.keys()), values)) + + def _restore_snapshot(self, snapshot: Dict[str, str]) -> None: + """Restore a snapshot to the machine.""" + values = [f"{key}={value}" for key, value in snapshot.items()] + self._sysctl(values) + + def _sysctl(self, cmd: List[str]) -> List[str]: + """Execute a sysctl command.""" + cmd = ["sysctl"] + cmd + logger.debug("Executing sysctl command: %s", cmd) + try: + return check_output(cmd, stderr=STDOUT, universal_newlines=True).splitlines() + except CalledProcessError as e: + msg = f"Error executing '{cmd}': {e.stdout}" + logger.error(msg) + raise CommandError(msg) + + def _parse_config(self, config: Dict[str, str]) -> None: + """Parse a config passed to the lib.""" + self._desired_config = {k: str(v) for k, v in config.items()} + + def _load_data(self) -> Dict[str, str]: + """Get merged config.""" + config = {} + if not SYSCTL_FILENAME.exists(): + return config + + with open(SYSCTL_FILENAME, "r") as f: + for line in f: + if line.startswith(("#", ";")) or not line.strip() or "=" not in line: + continue + + key, _, value = line.partition("=") + config[key.strip()] = value.strip() -# TODO: add your code here! Happy coding! + return config diff --git a/tests/integration/test_sysctl.py b/tests/integration/test_sysctl.py new file mode 100644 index 0000000..414cc0f --- /dev/null +++ b/tests/integration/test_sysctl.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + + +from pathlib import Path +from subprocess import check_output + +from charms.operator_libs_linux.v0 import sysctl + +EXPECTED_MERGED_RESULT = """# This config file was produced by sysctl lib v0.2 +# +# This file represents the output of the sysctl lib, which can combine multiple +# configurations into a single file like. +# test1 +net.ipv4.tcp_max_syn_backlog=4096 +# test2 +net.ipv4.tcp_window_scaling=2 +""" + + +def test_configure(): + cfg = sysctl.Config("test1") + cfg.configure({"net.ipv4.tcp_max_syn_backlog": "4096"}) + + result = check_output(["sysctl", "net.ipv4.tcp_max_syn_backlog"]) + + test_file = Path("/etc/sysctl.d/90-juju-test1") + merged_file = Path("/etc/sysctl.d/95-juju-sysctl.conf") + assert "net.ipv4.tcp_max_syn_backlog = 4096" in result.decode() + assert test_file.exists() + assert merged_file.exists() + + +def test_multiple_configure(): + # file from previous test still exists, so we only need to create a new one. + cfg_2 = sysctl.Config("test2") + cfg_2.configure({"net.ipv4.tcp_window_scaling": "2"}) + + test_file_2 = Path("/etc/sysctl.d/90-juju-test2") + merged_file = Path("/etc/sysctl.d/95-juju-sysctl.conf") + result = check_output( + ["sysctl", "net.ipv4.tcp_max_syn_backlog", "net.ipv4.tcp_window_scaling"] + ) + assert ( + "net.ipv4.tcp_max_syn_backlog = 4096\nnet.ipv4.tcp_window_scaling = 2\n" in result.decode() + ) + assert test_file_2.exists() + + with open(merged_file, "r") as f: + assert f.read() == EXPECTED_MERGED_RESULT + + +def test_remove(): + cfg = sysctl.Config("test") + cfg.remove() + + test_file = Path("/etc/sysctl.d/90-juju-test") + assert not test_file.exists() diff --git a/tests/unit/test_sysctl.py b/tests/unit/test_sysctl.py new file mode 100644 index 0000000..b8b9bde --- /dev/null +++ b/tests/unit/test_sysctl.py @@ -0,0 +1,313 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import tempfile +import unittest +from pathlib import Path +from subprocess import CalledProcessError +from unittest.mock import patch + +from charms.operator_libs_linux.v0 import sysctl + +permission_failure_output = """sysctl: permission denied on key "vm.swappiness", ignoring""" +partial_permission_failure_output = """sysctl: permission denied on key "vm.swappiness", ignoring +net.ipv4.tcp_max_syn_backlog = 4096 +""" + +TEST_OTHER_CHARM_FILE = """# othercharm +vm.swappiness=60 +net.ipv4.tcp_max_syn_backlog=4096 +""" +TEST_OTHER_CHARM_MERGED = """# This config file was produced by sysctl lib v0.2 +# +# This file represents the output of the sysctl lib, which can combine multiple +# configurations into a single file like. +# othercharm +vm.swappiness=60 +net.ipv4.tcp_max_syn_backlog=4096 +""" +TEST_MERGED_FILE = """# This config file was produced by sysctl lib v0.2 +# +# This file represents the output of the sysctl lib, which can combine multiple +# configurations into a single file like. +vm.max_map_count = 262144 +vm.swappiness=0 + +""" +TEST_UPDATE_MERGED_FILE = """# This config file was produced by sysctl lib v0.2 +# +# This file represents the output of the sysctl lib, which can combine multiple +# configurations into a single file like. +# test +vm.max_map_count=25500 +""" + + +def check_output_side_effects(*args, **kwargs): + if args[0] == ["sysctl", "-n", "vm.swappiness"]: + return "1" + if args[0] == ["sysctl", "-n", "vm.swappiness", "other_value"]: + return "1\n5" + elif args[0] == ["sysctl", "vm.swappiness=1", "other_value=5"]: + return "1\n5" + elif args[0] == ["sysctl", "vm.swappiness=0"]: + return permission_failure_output + elif args[0] == ["sysctl", "vm.swappiness=0", "net.ipv4.tcp_max_syn_backlog=4096"]: + return partial_permission_failure_output + elif args[0] == ["sysctl", "exception"]: + raise CalledProcessError(returncode=1, cmd=args[0], output="error on command") + + # Tests on 'update()' + elif args[0] == ["sysctl", "-n", "vm.max_map_count"]: + return "25000" + elif args[0] == ["sysctl", "vm.max_map_count=25500"]: + return "25500" + + +class TestSysctlConfig(unittest.TestCase): + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.tmp_dir = Path(tmp_dir.name) + self.addCleanup(tmp_dir.cleanup) + + # configured paths + sysctl.SYSCTL_DIRECTORY = self.tmp_dir + sysctl.SYSCTL_FILENAME = self.tmp_dir / "95-juju-sysctl.conf" + + self.loaded_values = {"vm.swappiness": "60", "vm.max_map_count": "25500"} + + @patch("charms.operator_libs_linux.v0.sysctl.check_output") + def test_update_new_values(self, mock_output): + mock_output.side_effect = check_output_side_effects + config = sysctl.Config("test") + + config.configure({"vm.max_map_count": "25500"}) + + self.assertEqual(config._desired_config, {"vm.max_map_count": "25500"}) + with open(self.tmp_dir / "95-juju-sysctl.conf", "r") as f: + assert f.read() == TEST_UPDATE_MERGED_FILE + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_update_with_validation_error(self, mock_load): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + + with self.assertRaises(sysctl.ValidationError) as e: + config.configure({"vm.max_map_count": "25000"}) + + self.assertEqual(e.exception.message, "Validation error for keys: ['vm.max_map_count']") + + def test_update_with_permission_error(self): + config = sysctl.Config("test") + + with self.assertRaises(sysctl.ApplyError) as e: + config.configure({"vm.swappiness": "0", "net.ipv4.tcp_max_syn_backlog": "4096"}) + + self.assertEqual(e.exception.message, "Unable to set params: ['vm.swappiness']") + + @patch("pathlib.Path.unlink") + @patch("charms.operator_libs_linux.v0.sysctl.Config._merge") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_remove(self, mock_load, mock_merge, mock_unlink): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + + config.remove() + + mock_unlink.assert_called() + mock_merge.assert_called() + + def test_load_data(self): + with open(self.tmp_dir / "95-juju-sysctl.conf", "w") as f: + f.write(TEST_MERGED_FILE) + + config = sysctl.Config(name="test") + + assert config._data == {"vm.swappiness": "0", "vm.max_map_count": "262144"} + + def test_load_data_no_path(self): + config = sysctl.Config(name="test") + + assert len(config) == 0 + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_merge(self, mock_load): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + with open(self.tmp_dir / "90-juju-othercharm", "w") as f: + f.write == TEST_OTHER_CHARM_FILE + + config._merge() + + assert (self.tmp_dir / "95-juju-sysctl.conf").exists + with open(self.tmp_dir / "95-juju-sysctl.conf", "r") as f: + f.read == TEST_OTHER_CHARM_MERGED + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_merge_without_own_file(self, mock_load): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + + with open(self.tmp_dir / "90-juju-test", "w") as f: + f.write == "# test\nvalue=1\n" + with open(self.tmp_dir / "90-juju-othercharm", "w") as f: + f.write == TEST_OTHER_CHARM_FILE + + config._merge(add_own_charm=False) + + assert (self.tmp_dir / "95-juju-sysctl.conf").exists + with open(self.tmp_dir / "95-juju-sysctl.conf", "r") as f: + f.read == TEST_OTHER_CHARM_MERGED + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_validate_different_keys(self, mock_load): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + + config._desired_config = {"non_conflicting": "0"} + result = config._validate() + + assert result == [] + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_validate_same_keys_and_values(self, mock_load): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + + config._desired_config = {"vm.swappiness": "60"} + result = config._validate() + + assert result == [] + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_validate_same_keys_different_values(self, mock_load): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + + config._desired_config = {"vm.swappiness": "1"} + result = config._validate() + + assert result == ["vm.swappiness"] + + def test_create_charm_file(self): + config = sysctl.Config("test") + + config._desired_config = {"vm.swappiness": "0", "other_value": "10"} + config._create_charm_file() + + with open(self.tmp_dir / "90-juju-test", "r") as f: + assert f.read() == "# test\nvm.swappiness=0\nother_value=10\n" + + @patch("charms.operator_libs_linux.v0.sysctl.check_output") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_create_snapshot(self, mock_load, mock_output): + mock_load.return_value = self.loaded_values + mock_output.side_effect = check_output_side_effects + config = sysctl.Config("test") + + config._desired_config = {"vm.swappiness": "0", "other_value": "10"} + snapshot = config._create_snapshot() + + assert mock_output.called_with(["sysctl", "vm.swappiness", "-n"]) + assert mock_output.called_with(["sysctl", "other_value", "-n"]) + assert snapshot == {"vm.swappiness": "1", "other_value": "5"} + + @patch("charms.operator_libs_linux.v0.sysctl.check_output") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_restore_snapshot(self, mock_load, mock_output): + mock_load.return_value = self.loaded_values + mock_output.side_effect = check_output_side_effects + config = sysctl.Config("test") + + snapshot = {"vm.swappiness": "1", "other_value": "5"} + config._restore_snapshot(snapshot) + + assert mock_output.called_with(["sysctl", "vm.swappiness=1", "other_value=5"]) + + @patch("charms.operator_libs_linux.v0.sysctl.check_output") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_syctl(self, mock_load, mock_output): + mock_load.return_value = self.loaded_values + mock_output.side_effect = check_output_side_effects + config = sysctl.Config("test") + + result = config._sysctl(["-n", "vm.swappiness"]) + + assert mock_output.called_with(["sysctl", "-n", "vm.swappiness"]) + assert result == ["1"] + + @patch("charms.operator_libs_linux.v0.sysctl.check_output") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_syctl_error(self, mock_load, mock_output): + mock_load.return_value = self.loaded_values + mock_output.side_effect = check_output_side_effects + config = sysctl.Config("test") + + with self.assertRaises(sysctl.CommandError) as e: + config._sysctl(["exception"]) + + assert mock_output.called_with(["sysctl", "exception"]) + assert e.exception.message == "Error executing '['sysctl', 'exception']': error on command" + + @patch("charms.operator_libs_linux.v0.sysctl.Config._sysctl") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_apply_without_failed_values(self, mock_load, mock_sysctl): + mock_load.return_value = self.loaded_values + mock_sysctl.return_value = ["vm.swappiness = 0"] + config = sysctl.Config("test") + + config._desired_config = {"vm.swappiness": "0"} + config._apply() + + assert mock_sysctl.called_with(["vm.swappiness=0"]) + + @patch("charms.operator_libs_linux.v0.sysctl.Config._sysctl") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_apply_with_failed_values(self, mock_load, mock_sysctl): + mock_load.return_value = self.loaded_values + mock_sysctl.return_value = [permission_failure_output] + config = sysctl.Config("test") + + config._desired_config = {"vm.swappiness": "0"} + with self.assertRaises(sysctl.ApplyError) as e: + config._apply() + + assert mock_sysctl.called_with(["vm.swappiness=0"]) + self.assertEqual(e.exception.message, "Unable to set params: ['vm.swappiness']") + + @patch("charms.operator_libs_linux.v0.sysctl.Config._sysctl") + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_apply_with_partial_failed_values(self, mock_load, mock_sysctl): + mock_load.return_value = self.loaded_values + mock_sysctl.return_value = [permission_failure_output] + config = sysctl.Config("test") + + config._desired_config = {"vm.swappiness": "0", "net.ipv4.tcp_max_syn_backlog": "4096"} + with self.assertRaises(sysctl.ApplyError) as e: + config._apply() + + assert mock_sysctl.called_with(["vm.swappiness=0", "net.ipv4.tcp_max_syn_backlog=4096"]) + self.assertEqual(e.exception.message, "Unable to set params: ['vm.swappiness']") + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_parse_config(self, _): + config = sysctl.Config("test") + + config._parse_config({"key1": "10", "key2": "20"}) + + self.assertEqual(config._desired_config, {"key1": "10", "key2": "20"}) + + @patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") + def test_class_methods(self, mock_load): + mock_load.return_value = self.loaded_values + config = sysctl.Config("test") + + # __contains__ + self.assertIn("vm.swappiness", config) + # __len__ + self.assertEqual(len(config), 2) + # __iter__ + self.assertListEqual(list(config), list(self.loaded_values.keys())) + # __getitem__ + for key, value in self.loaded_values.items(): + self.assertEqual(config[key], value)