1#!/usr/bin/env python3 2"""systemctl: subset of systemctl used for image construction 3 4Mask/preset systemd units 5""" 6 7import argparse 8import fnmatch 9import os 10import re 11import sys 12 13from collections import namedtuple 14from itertools import chain 15from pathlib import Path 16 17version = 1.0 18 19ROOT = Path("/") 20SYSCONFDIR = Path("etc") 21BASE_LIBDIR = Path("lib") 22LIBDIR = Path("usr", "lib") 23 24locations = list() 25 26 27class SystemdFile(): 28 """Class representing a single systemd configuration file""" 29 def __init__(self, root, path, instance_unit_name): 30 self.sections = dict() 31 self._parse(root, path) 32 dirname = os.path.basename(path.name) + ".d" 33 for location in locations: 34 files = (root / location / "system" / dirname).glob("*.conf") 35 if instance_unit_name: 36 inst_dirname = instance_unit_name + ".d" 37 files = chain(files, (root / location / "system" / inst_dirname).glob("*.conf")) 38 for path2 in sorted(files): 39 self._parse(root, path2) 40 41 def _parse(self, root, path): 42 """Parse a systemd syntax configuration file 43 44 Args: 45 path: A pathlib.Path object pointing to the file 46 47 """ 48 skip_re = re.compile(r"^\s*([#;]|$)") 49 section_re = re.compile(r"^\s*\[(?P<section>.*)\]") 50 kv_re = re.compile(r"^\s*(?P<key>[^\s]+)\s*=\s*(?P<value>.*)") 51 section = None 52 53 if path.is_symlink(): 54 try: 55 path.resolve() 56 except FileNotFoundError: 57 # broken symlink, try relative to root 58 path = root / Path(os.readlink(str(path))).relative_to(ROOT) 59 60 with path.open() as f: 61 for line in f: 62 if skip_re.match(line): 63 continue 64 65 line = line.strip() 66 m = section_re.match(line) 67 if m: 68 if m.group('section') not in self.sections: 69 section = dict() 70 self.sections[m.group('section')] = section 71 else: 72 section = self.sections[m.group('section')] 73 continue 74 75 while line.endswith("\\"): 76 line += f.readline().rstrip("\n") 77 78 m = kv_re.match(line) 79 k = m.group('key') 80 v = m.group('value') 81 if k not in section: 82 section[k] = list() 83 section[k].extend(v.split()) 84 85 def get(self, section, prop): 86 """Get a property from section 87 88 Args: 89 section: Section to retrieve property from 90 prop: Property to retrieve 91 92 Returns: 93 List representing all properties of type prop in section. 94 95 Raises: 96 KeyError: if ``section`` or ``prop`` not found 97 """ 98 return self.sections[section][prop] 99 100 101class Presets(): 102 """Class representing all systemd presets""" 103 def __init__(self, scope, root): 104 self.directives = list() 105 self._collect_presets(scope, root) 106 107 def _parse_presets(self, presets): 108 """Parse presets out of a set of preset files""" 109 skip_re = re.compile(r"^\s*([#;]|$)") 110 directive_re = re.compile(r"^\s*(?P<action>enable|disable)\s+(?P<unit_name>(.+))") 111 112 Directive = namedtuple("Directive", "action unit_name") 113 for preset in presets: 114 with preset.open() as f: 115 for line in f: 116 m = directive_re.match(line) 117 if m: 118 directive = Directive(action=m.group('action'), 119 unit_name=m.group('unit_name')) 120 self.directives.append(directive) 121 elif skip_re.match(line): 122 pass 123 else: 124 sys.exit("Unparsed preset line in {}".format(preset)) 125 126 def _collect_presets(self, scope, root): 127 """Collect list of preset files""" 128 presets = dict() 129 for location in locations: 130 paths = (root / location / scope).glob("*.preset") 131 for path in paths: 132 # earlier names override later ones 133 if path.name not in presets: 134 presets[path.name] = path 135 136 self._parse_presets([v for k, v in sorted(presets.items())]) 137 138 def state(self, unit_name): 139 """Return state of preset for unit_name 140 141 Args: 142 presets: set of presets 143 unit_name: name of the unit 144 145 Returns: 146 None: no matching preset 147 `enable`: unit_name is enabled 148 `disable`: unit_name is disabled 149 """ 150 for directive in self.directives: 151 if fnmatch.fnmatch(unit_name, directive.unit_name): 152 return directive.action 153 154 return None 155 156 157def add_link(path, target): 158 try: 159 path.parent.mkdir(parents=True) 160 except FileExistsError: 161 pass 162 if not path.is_symlink(): 163 print("ln -s {} {}".format(target, path)) 164 path.symlink_to(target) 165 166 167class SystemdUnitNotFoundError(Exception): 168 def __init__(self, path, unit): 169 self.path = path 170 self.unit = unit 171 172 173class SystemdUnit(): 174 def __init__(self, root, unit): 175 self.root = root 176 self.unit = unit 177 self.config = None 178 179 def _path_for_unit(self, unit): 180 for location in locations: 181 path = self.root / location / "system" / unit 182 if path.exists() or path.is_symlink(): 183 return path 184 185 raise SystemdUnitNotFoundError(self.root, unit) 186 187 def _process_deps(self, config, service, location, prop, dirstem): 188 systemdir = self.root / SYSCONFDIR / "systemd" / "system" 189 190 target = ROOT / location.relative_to(self.root) 191 try: 192 for dependent in config.get('Install', prop): 193 wants = systemdir / "{}.{}".format(dependent, dirstem) / service 194 add_link(wants, target) 195 196 except KeyError: 197 pass 198 199 def enable(self, caller_unit=None): 200 # if we're enabling an instance, first extract the actual instance 201 # then figure out what the template unit is 202 template = re.match(r"[^@]+@(?P<instance>[^\.]*)\.", self.unit) 203 instance_unit_name = None 204 if template: 205 instance = template.group('instance') 206 if instance != "": 207 instance_unit_name = self.unit 208 unit = re.sub(r"@[^\.]*\.", "@.", self.unit, 1) 209 else: 210 instance = None 211 unit = self.unit 212 213 path = self._path_for_unit(unit) 214 215 if path.is_symlink(): 216 # ignore aliases 217 return 218 219 config = SystemdFile(self.root, path, instance_unit_name) 220 if instance == "": 221 try: 222 default_instance = config.get('Install', 'DefaultInstance')[0] 223 except KeyError: 224 # no default instance, so nothing to enable 225 return 226 227 service = self.unit.replace("@.", 228 "@{}.".format(default_instance)) 229 else: 230 service = self.unit 231 232 self._process_deps(config, service, path, 'WantedBy', 'wants') 233 self._process_deps(config, service, path, 'RequiredBy', 'requires') 234 235 try: 236 for also in config.get('Install', 'Also'): 237 try: 238 if caller_unit != also: 239 SystemdUnit(self.root, also).enable(unit) 240 except SystemdUnitNotFoundError as e: 241 sys.exit("Error: Systemctl also enable issue with %s (%s)" % (service, e.unit)) 242 243 except KeyError: 244 pass 245 246 systemdir = self.root / SYSCONFDIR / "systemd" / "system" 247 target = ROOT / path.relative_to(self.root) 248 try: 249 for dest in config.get('Install', 'Alias'): 250 alias = systemdir / dest 251 add_link(alias, target) 252 253 except KeyError: 254 pass 255 256 def mask(self): 257 systemdir = self.root / SYSCONFDIR / "systemd" / "system" 258 add_link(systemdir / self.unit, "/dev/null") 259 260 261def collect_services(root): 262 """Collect list of service files""" 263 services = set() 264 for location in locations: 265 paths = (root / location / "system").glob("*") 266 for path in paths: 267 if path.is_dir(): 268 continue 269 services.add(path.name) 270 271 return services 272 273 274def preset_all(root): 275 presets = Presets('system-preset', root) 276 services = collect_services(root) 277 278 for service in services: 279 state = presets.state(service) 280 281 if state == "enable" or state is None: 282 try: 283 SystemdUnit(root, service).enable() 284 except SystemdUnitNotFoundError: 285 sys.exit("Error: Systemctl preset_all issue in %s" % service) 286 287 # If we populate the systemd links we also create /etc/machine-id, which 288 # allows systemd to boot with the filesystem read-only before generating 289 # a real value and then committing it back. 290 # 291 # For the stateless configuration, where /etc is generated at runtime 292 # (for example on a tmpfs), this script shouldn't run at all and we 293 # allow systemd to completely populate /etc. 294 (root / SYSCONFDIR / "machine-id").touch() 295 296 297def main(): 298 if sys.version_info < (3, 4, 0): 299 sys.exit("Python 3.4 or greater is required") 300 301 parser = argparse.ArgumentParser() 302 parser.add_argument('command', nargs='?', choices=['enable', 'mask', 303 'preset-all']) 304 parser.add_argument('service', nargs=argparse.REMAINDER) 305 parser.add_argument('--root') 306 parser.add_argument('--preset-mode', 307 choices=['full', 'enable-only', 'disable-only'], 308 default='full') 309 310 args = parser.parse_args() 311 312 root = Path(args.root) if args.root else ROOT 313 314 locations.append(SYSCONFDIR / "systemd") 315 # Handle the usrmerge case by ignoring /lib when it's a symlink 316 if not (root / BASE_LIBDIR).is_symlink(): 317 locations.append(BASE_LIBDIR / "systemd") 318 locations.append(LIBDIR / "systemd") 319 320 command = args.command 321 if not command: 322 parser.print_help() 323 return 0 324 325 if command == "mask": 326 for service in args.service: 327 try: 328 SystemdUnit(root, service).mask() 329 except SystemdUnitNotFoundError as e: 330 sys.exit("Error: Systemctl main mask issue in %s (%s)" % (service, e.unit)) 331 elif command == "enable": 332 for service in args.service: 333 try: 334 SystemdUnit(root, service).enable() 335 except SystemdUnitNotFoundError as e: 336 sys.exit("Error: Systemctl main enable issue in %s (%s)" % (service, e.unit)) 337 elif command == "preset-all": 338 if len(args.service) != 0: 339 sys.exit("Too many arguments.") 340 if args.preset_mode != "enable-only": 341 sys.exit("Only enable-only is supported as preset-mode.") 342 preset_all(root) 343 else: 344 raise RuntimeError() 345 346 347if __name__ == '__main__': 348 main() 349