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