|
| 1 | +# Copyright 2023 Canonical Ltd. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Handler for the sysctl config. |
| 16 | +
|
| 17 | +This library allows your charm to create and configure sysctl options to the machine. |
| 18 | +
|
| 19 | +Validation and merge capabilities are added, for situations where more than one application |
| 20 | +are setting values. The following files can be created: |
| 21 | +
|
| 22 | +- /etc/sysctl.d/90-juju-<app-name> |
| 23 | + Requirements from one application requesting to configure the values. |
| 24 | +
|
| 25 | +- /etc/sysctl.d/95-juju-sysctl.conf |
| 26 | + Merged file resulting from all other `90-juju-*` application files. |
| 27 | +
|
| 28 | +
|
| 29 | +A charm using the sysctl lib will need a data structure like the following: |
| 30 | +``` |
| 31 | +{ |
| 32 | +"vm.swappiness": "1", |
| 33 | +"vm.max_map_count": "262144", |
| 34 | +"vm.dirty_ratio": "80", |
| 35 | +"vm.dirty_background_ratio": "5", |
| 36 | +"net.ipv4.tcp_max_syn_backlog": "4096", |
| 37 | +} |
| 38 | +``` |
| 39 | +
|
| 40 | +Now, it can use that template within the charm, or just declare the values directly: |
| 41 | +
|
| 42 | +```python |
| 43 | +from charms.operator_libs_linux.v0 import sysctl |
| 44 | +
|
| 45 | +class MyCharm(CharmBase): |
| 46 | +
|
| 47 | + def __init__(self, *args): |
| 48 | + ... |
| 49 | + self.sysctl = sysctl.Config(self.meta.name) |
| 50 | +
|
| 51 | + self.framework.observe(self.on.install, self._on_install) |
| 52 | + self.framework.observe(self.on.remove, self._on_remove) |
| 53 | +
|
| 54 | + def _on_install(self, _): |
| 55 | + # Altenatively, read the values from a template |
| 56 | + sysctl_data = {"net.ipv4.tcp_max_syn_backlog": "4096"}} |
| 57 | +
|
| 58 | + try: |
| 59 | + self.sysctl.configure(config=sysctl_data) |
| 60 | + except (sysctl.ApplyError, sysctl.ValidationError) as e: |
| 61 | + logger.error(f"Error setting values on sysctl: {e.message}") |
| 62 | + self.unit.status = BlockedStatus("Sysctl config not possible") |
| 63 | + except sysctl.CommandError: |
| 64 | + logger.error("Error on sysctl") |
| 65 | +
|
| 66 | + def _on_remove(self, _): |
| 67 | + self.sysctl.remove() |
| 68 | +``` |
| 69 | +""" |
| 70 | + |
| 71 | +import logging |
| 72 | +import re |
| 73 | +from pathlib import Path |
| 74 | +from subprocess import STDOUT, CalledProcessError, check_output |
| 75 | +from typing import Dict, List |
| 76 | + |
| 77 | +logger = logging.getLogger(__name__) |
| 78 | + |
| 79 | +# The unique Charmhub library identifier, never change it |
| 80 | +LIBID = "17a6cd4d80104d15b10f9c2420ab3266" |
| 81 | + |
| 82 | +# Increment this major API version when introducing breaking changes |
| 83 | +LIBAPI = 0 |
| 84 | + |
| 85 | +# Increment this PATCH version before using `charmcraft publish-lib` or reset |
| 86 | +# to 0 if you are raising the major API version |
| 87 | +LIBPATCH = 4 |
| 88 | + |
| 89 | +CHARM_FILENAME_PREFIX = "90-juju-" |
| 90 | +SYSCTL_DIRECTORY = Path("/etc/sysctl.d") |
| 91 | +SYSCTL_FILENAME = SYSCTL_DIRECTORY / "95-juju-sysctl.conf" |
| 92 | +SYSCTL_HEADER = f"""# This config file was produced by sysctl lib v{LIBAPI}.{LIBPATCH} |
| 93 | +# |
| 94 | +# This file represents the output of the sysctl lib, which can combine multiple |
| 95 | +# configurations into a single file like. |
| 96 | +""" |
| 97 | + |
| 98 | + |
| 99 | +class Error(Exception): |
| 100 | + """Base class of most errors raised by this library.""" |
| 101 | + |
| 102 | + @property |
| 103 | + def message(self): |
| 104 | + """Return the message passed as an argument.""" |
| 105 | + return self.args[0] |
| 106 | + |
| 107 | + |
| 108 | +class CommandError(Error): |
| 109 | + """Raised when there's an error running sysctl command.""" |
| 110 | + |
| 111 | + |
| 112 | +class ApplyError(Error): |
| 113 | + """Raised when there's an error applying values in sysctl.""" |
| 114 | + |
| 115 | + |
| 116 | +class ValidationError(Error): |
| 117 | + """Exception representing value validation error.""" |
| 118 | + |
| 119 | + |
| 120 | +class Config(Dict): |
| 121 | + """Represents the state of the config that a charm wants to enforce.""" |
| 122 | + |
| 123 | + _apply_re = re.compile(r"sysctl: permission denied on key \"([a-z_\.]+)\", ignoring$") |
| 124 | + |
| 125 | + def __init__(self, name: str) -> None: |
| 126 | + self.name = name |
| 127 | + self._data = self._load_data() |
| 128 | + |
| 129 | + def __contains__(self, key: str) -> bool: |
| 130 | + """Check if key is in config.""" |
| 131 | + return key in self._data |
| 132 | + |
| 133 | + def __len__(self): |
| 134 | + """Get size of config.""" |
| 135 | + return len(self._data) |
| 136 | + |
| 137 | + def __iter__(self): |
| 138 | + """Iterate over config.""" |
| 139 | + return iter(self._data) |
| 140 | + |
| 141 | + def __getitem__(self, key: str) -> str: |
| 142 | + """Get value for key form config.""" |
| 143 | + return self._data[key] |
| 144 | + |
| 145 | + @property |
| 146 | + def charm_filepath(self) -> Path: |
| 147 | + """Name for resulting charm config file.""" |
| 148 | + return SYSCTL_DIRECTORY / f"{CHARM_FILENAME_PREFIX}{self.name}" |
| 149 | + |
| 150 | + def configure(self, config: Dict[str, str]) -> None: |
| 151 | + """Configure sysctl options with a desired set of params. |
| 152 | +
|
| 153 | + Args: |
| 154 | + config: dictionary with keys to configure: |
| 155 | + ``` |
| 156 | + {"vm.swappiness": "10", ...} |
| 157 | + ``` |
| 158 | + """ |
| 159 | + self._parse_config(config) |
| 160 | + |
| 161 | + # NOTE: case where own charm calls configure() more than once. |
| 162 | + if self.charm_filepath.exists(): |
| 163 | + self._merge(add_own_charm=False) |
| 164 | + |
| 165 | + conflict = self._validate() |
| 166 | + if conflict: |
| 167 | + raise ValidationError(f"Validation error for keys: {conflict}") |
| 168 | + |
| 169 | + snapshot = self._create_snapshot() |
| 170 | + logger.debug("Created snapshot for keys: %s", snapshot) |
| 171 | + try: |
| 172 | + self._apply() |
| 173 | + except ApplyError: |
| 174 | + self._restore_snapshot(snapshot) |
| 175 | + raise |
| 176 | + |
| 177 | + self._create_charm_file() |
| 178 | + self._merge() |
| 179 | + |
| 180 | + def remove(self) -> None: |
| 181 | + """Remove config for charm. |
| 182 | +
|
| 183 | + The removal process won't apply any sysctl configuration. It will only merge files from |
| 184 | + remaining charms. |
| 185 | + """ |
| 186 | + self.charm_filepath.unlink(missing_ok=True) |
| 187 | + logger.info("Charm config file %s was removed", self.charm_filepath) |
| 188 | + self._merge() |
| 189 | + |
| 190 | + def _validate(self) -> List[str]: |
| 191 | + """Validate the desired config params against merged ones.""" |
| 192 | + common_keys = set(self._data.keys()) & set(self._desired_config.keys()) |
| 193 | + conflict_keys = [] |
| 194 | + for key in common_keys: |
| 195 | + if self._data[key] != self._desired_config[key]: |
| 196 | + logger.warning( |
| 197 | + "Values for key '%s' are different: %s != %s", |
| 198 | + key, |
| 199 | + self._data[key], |
| 200 | + self._desired_config[key], |
| 201 | + ) |
| 202 | + conflict_keys.append(key) |
| 203 | + |
| 204 | + return conflict_keys |
| 205 | + |
| 206 | + def _create_charm_file(self) -> None: |
| 207 | + """Write the charm file.""" |
| 208 | + with open(self.charm_filepath, "w") as f: |
| 209 | + f.write(f"# {self.name}\n") |
| 210 | + for key, value in self._desired_config.items(): |
| 211 | + f.write(f"{key}={value}\n") |
| 212 | + |
| 213 | + def _merge(self, add_own_charm=True) -> None: |
| 214 | + """Create the merged sysctl file. |
| 215 | +
|
| 216 | + Args: |
| 217 | + add_own_charm : bool, if false it will skip the charm file from the merge. |
| 218 | + """ |
| 219 | + # get all files that start by 90-juju- |
| 220 | + data = [SYSCTL_HEADER] |
| 221 | + paths = set(SYSCTL_DIRECTORY.glob(f"{CHARM_FILENAME_PREFIX}*")) |
| 222 | + if not add_own_charm: |
| 223 | + paths.discard(self.charm_filepath) |
| 224 | + |
| 225 | + for path in paths: |
| 226 | + with open(path, "r") as f: |
| 227 | + data += f.readlines() |
| 228 | + with open(SYSCTL_FILENAME, "w") as f: |
| 229 | + f.writelines(data) |
| 230 | + |
| 231 | + # Reload data with newly created file. |
| 232 | + self._data = self._load_data() |
| 233 | + |
| 234 | + def _apply(self) -> None: |
| 235 | + """Apply values to machine.""" |
| 236 | + cmd = [f"{key}={value}" for key, value in self._desired_config.items()] |
| 237 | + result = self._sysctl(cmd) |
| 238 | + failed_values = [ |
| 239 | + self._apply_re.match(line) for line in result if self._apply_re.match(line) |
| 240 | + ] |
| 241 | + logger.debug("Failed values: %s", failed_values) |
| 242 | + |
| 243 | + if failed_values: |
| 244 | + msg = f"Unable to set params: {[f.group(1) for f in failed_values]}" |
| 245 | + logger.error(msg) |
| 246 | + raise ApplyError(msg) |
| 247 | + |
| 248 | + def _create_snapshot(self) -> Dict[str, str]: |
| 249 | + """Create a snapshot of config options that are going to be set.""" |
| 250 | + cmd = ["-n"] + list(self._desired_config.keys()) |
| 251 | + values = self._sysctl(cmd) |
| 252 | + return dict(zip(list(self._desired_config.keys()), values)) |
| 253 | + |
| 254 | + def _restore_snapshot(self, snapshot: Dict[str, str]) -> None: |
| 255 | + """Restore a snapshot to the machine.""" |
| 256 | + values = [f"{key}={value}" for key, value in snapshot.items()] |
| 257 | + self._sysctl(values) |
| 258 | + |
| 259 | + def _sysctl(self, cmd: List[str]) -> List[str]: |
| 260 | + """Execute a sysctl command.""" |
| 261 | + cmd = ["sysctl"] + cmd |
| 262 | + logger.debug("Executing sysctl command: %s", cmd) |
| 263 | + try: |
| 264 | + return check_output(cmd, stderr=STDOUT, universal_newlines=True).splitlines() |
| 265 | + except CalledProcessError as e: |
| 266 | + msg = f"Error executing '{cmd}': {e.stdout}" |
| 267 | + logger.error(msg) |
| 268 | + raise CommandError(msg) |
| 269 | + |
| 270 | + def _parse_config(self, config: Dict[str, str]) -> None: |
| 271 | + """Parse a config passed to the lib.""" |
| 272 | + self._desired_config = {k: str(v) for k, v in config.items()} |
| 273 | + |
| 274 | + def _load_data(self) -> Dict[str, str]: |
| 275 | + """Get merged config.""" |
| 276 | + config = {} |
| 277 | + if not SYSCTL_FILENAME.exists(): |
| 278 | + return config |
| 279 | + |
| 280 | + with open(SYSCTL_FILENAME, "r") as f: |
| 281 | + for line in f: |
| 282 | + if line.startswith(("#", ";")) or not line.strip() or "=" not in line: |
| 283 | + continue |
| 284 | + |
| 285 | + key, _, value = line.partition("=") |
| 286 | + config[key.strip()] = value.strip() |
| 287 | + |
| 288 | + return config |
0 commit comments