blob: e8c3d2d1ee4050b0c5519d8ef73eb377c516c21b [file] [log] [blame]
Brad Bishopc342db32019-05-15 21:57:59 -04001#!/usr/bin/env python3
2"""systemctl: subset of systemctl used for image construction
Patrick Williamsc124f4f2015-09-15 14:41:29 -05003
Brad Bishopc342db32019-05-15 21:57:59 -04004Mask/preset systemd units
5"""
Patrick Williamsc124f4f2015-09-15 14:41:29 -05006
Brad Bishopc342db32019-05-15 21:57:59 -04007import argparse
8import fnmatch
9import os
10import re
11import sys
Patrick Williamsc124f4f2015-09-15 14:41:29 -050012
Brad Bishopc342db32019-05-15 21:57:59 -040013from collections import namedtuple
14from pathlib import Path
Patrick Williamsc124f4f2015-09-15 14:41:29 -050015
Brad Bishopc342db32019-05-15 21:57:59 -040016version = 1.0
Patrick Williamsc124f4f2015-09-15 14:41:29 -050017
Brad Bishopc342db32019-05-15 21:57:59 -040018ROOT = Path("/")
19SYSCONFDIR = Path("etc")
20BASE_LIBDIR = Path("lib")
21LIBDIR = Path("usr", "lib")
Patrick Williamsc124f4f2015-09-15 14:41:29 -050022
Brad Bishopc342db32019-05-15 21:57:59 -040023locations = list()
Patrick Williamsc124f4f2015-09-15 14:41:29 -050024
Patrick Williamsc124f4f2015-09-15 14:41:29 -050025
Brad Bishopc342db32019-05-15 21:57:59 -040026class SystemdFile():
27 """Class representing a single systemd configuration file"""
28 def __init__(self, root, path):
29 self.sections = dict()
30 self._parse(root, path)
Brad Bishop08902b02019-08-20 09:16:51 -040031 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)
Patrick Williamsc124f4f2015-09-15 14:41:29 -050035
Brad Bishopc342db32019-05-15 21:57:59 -040036 def _parse(self, root, path):
37 """Parse a systemd syntax configuration file
Patrick Williamsc124f4f2015-09-15 14:41:29 -050038
Brad Bishopc342db32019-05-15 21:57:59 -040039 Args:
40 path: A pathlib.Path object pointing to the file
Patrick Williamsc124f4f2015-09-15 14:41:29 -050041
Brad Bishopc342db32019-05-15 21:57:59 -040042 """
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
Patrick Williamsc124f4f2015-09-15 14:41:29 -050047
Brad Bishopc342db32019-05-15 21:57:59 -040048 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)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050054
Brad Bishopc342db32019-05-15 21:57:59 -040055 with path.open() as f:
56 for line in f:
57 if skip_re.match(line):
58 continue
Brad Bishopec8c5652018-11-05 19:23:07 -050059
Brad Bishopa34c0302019-09-23 22:34:48 -040060 line = line.strip()
Brad Bishopc342db32019-05-15 21:57:59 -040061 m = section_re.match(line)
62 if m:
Brad Bishop08902b02019-08-20 09:16:51 -040063 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')]
Brad Bishopc342db32019-05-15 21:57:59 -040068 continue
Patrick Williamsc124f4f2015-09-15 14:41:29 -050069
Brad Bishopc342db32019-05-15 21:57:59 -040070 while line.endswith("\\"):
71 line += f.readline().rstrip("\n")
Patrick Williamsc124f4f2015-09-15 14:41:29 -050072
Brad Bishopc342db32019-05-15 21:57:59 -040073 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())
Patrick Williamsc124f4f2015-09-15 14:41:29 -050079
Brad Bishopc342db32019-05-15 21:57:59 -040080 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):
William A. Kennington IIIac69b482021-06-02 12:28:27 -0700163 def __init__(self, path, unit):
164 self.path = path
165 self.unit = unit
Brad Bishopc342db32019-05-15 21:57:59 -0400166
167
168class SystemdUnit():
169 def __init__(self, root, unit):
170 self.root = root
171 self.unit = unit
172 self.config = None
173
174 def _path_for_unit(self, unit):
175 for location in locations:
176 path = self.root / location / "system" / unit
Andrew Geissler82c905d2020-04-13 13:39:40 -0500177 if path.exists() or path.is_symlink():
Brad Bishopc342db32019-05-15 21:57:59 -0400178 return path
179
180 raise SystemdUnitNotFoundError(self.root, unit)
181
182 def _process_deps(self, config, service, location, prop, dirstem):
183 systemdir = self.root / SYSCONFDIR / "systemd" / "system"
184
185 target = ROOT / location.relative_to(self.root)
186 try:
187 for dependent in config.get('Install', prop):
188 wants = systemdir / "{}.{}".format(dependent, dirstem) / service
189 add_link(wants, target)
190
191 except KeyError:
192 pass
193
194 def enable(self):
195 # if we're enabling an instance, first extract the actual instance
196 # then figure out what the template unit is
197 template = re.match(r"[^@]+@(?P<instance>[^\.]*)\.", self.unit)
198 if template:
199 instance = template.group('instance')
200 unit = re.sub(r"@[^\.]*\.", "@.", self.unit, 1)
201 else:
202 instance = None
203 unit = self.unit
204
205 path = self._path_for_unit(unit)
206
207 if path.is_symlink():
208 # ignore aliases
209 return
210
211 config = SystemdFile(self.root, path)
212 if instance == "":
213 try:
214 default_instance = config.get('Install', 'DefaultInstance')[0]
215 except KeyError:
216 # no default instance, so nothing to enable
217 return
218
219 service = self.unit.replace("@.",
220 "@{}.".format(default_instance))
221 else:
222 service = self.unit
223
224 self._process_deps(config, service, path, 'WantedBy', 'wants')
225 self._process_deps(config, service, path, 'RequiredBy', 'requires')
226
227 try:
228 for also in config.get('Install', 'Also'):
William A. Kennington IIIac69b482021-06-02 12:28:27 -0700229 try:
230 SystemdUnit(self.root, also).enable()
231 except SystemdUnitNotFoundError as e:
232 sys.exit("Error: Systemctl also enable issue with %s (%s)" % (service, e.unit))
Brad Bishopc342db32019-05-15 21:57:59 -0400233
234 except KeyError:
235 pass
236
237 systemdir = self.root / SYSCONFDIR / "systemd" / "system"
238 target = ROOT / path.relative_to(self.root)
239 try:
240 for dest in config.get('Install', 'Alias'):
241 alias = systemdir / dest
242 add_link(alias, target)
243
244 except KeyError:
245 pass
246
247 def mask(self):
248 systemdir = self.root / SYSCONFDIR / "systemd" / "system"
249 add_link(systemdir / self.unit, "/dev/null")
250
251
252def collect_services(root):
253 """Collect list of service files"""
254 services = set()
255 for location in locations:
256 paths = (root / location / "system").glob("*")
257 for path in paths:
258 if path.is_dir():
259 continue
260 services.add(path.name)
261
262 return services
263
264
265def preset_all(root):
266 presets = Presets('system-preset', root)
267 services = collect_services(root)
268
269 for service in services:
270 state = presets.state(service)
271
272 if state == "enable" or state is None:
William A. Kennington IIIac69b482021-06-02 12:28:27 -0700273 try:
274 SystemdUnit(root, service).enable()
275 except SystemdUnitNotFoundError:
276 sys.exit("Error: Systemctl preset_all issue in %s" % service)
Brad Bishopc342db32019-05-15 21:57:59 -0400277
278 # If we populate the systemd links we also create /etc/machine-id, which
279 # allows systemd to boot with the filesystem read-only before generating
280 # a real value and then committing it back.
281 #
282 # For the stateless configuration, where /etc is generated at runtime
283 # (for example on a tmpfs), this script shouldn't run at all and we
284 # allow systemd to completely populate /etc.
285 (root / SYSCONFDIR / "machine-id").touch()
286
287
288def main():
289 if sys.version_info < (3, 4, 0):
290 sys.exit("Python 3.4 or greater is required")
291
292 parser = argparse.ArgumentParser()
Andrew Geissler09209ee2020-12-13 08:44:15 -0600293 parser.add_argument('command', nargs='?', choices=['enable', 'mask',
Brad Bishopc342db32019-05-15 21:57:59 -0400294 'preset-all'])
295 parser.add_argument('service', nargs=argparse.REMAINDER)
296 parser.add_argument('--root')
297 parser.add_argument('--preset-mode',
298 choices=['full', 'enable-only', 'disable-only'],
299 default='full')
300
301 args = parser.parse_args()
302
303 root = Path(args.root) if args.root else ROOT
304
305 locations.append(SYSCONFDIR / "systemd")
306 # Handle the usrmerge case by ignoring /lib when it's a symlink
307 if not (root / BASE_LIBDIR).is_symlink():
308 locations.append(BASE_LIBDIR / "systemd")
309 locations.append(LIBDIR / "systemd")
310
Andrew Geissler09209ee2020-12-13 08:44:15 -0600311 command = args.command
312 if not command:
313 parser.print_help()
314 return 0
315
Brad Bishopc342db32019-05-15 21:57:59 -0400316 if command == "mask":
317 for service in args.service:
William A. Kennington IIIac69b482021-06-02 12:28:27 -0700318 try:
319 SystemdUnit(root, service).mask()
320 except SystemdUnitNotFoundError as e:
321 sys.exit("Error: Systemctl main mask issue in %s (%s)" % (service, e.unit))
Brad Bishopc342db32019-05-15 21:57:59 -0400322 elif command == "enable":
323 for service in args.service:
William A. Kennington IIIac69b482021-06-02 12:28:27 -0700324 try:
325 SystemdUnit(root, service).enable()
326 except SystemdUnitNotFoundError as e:
327 sys.exit("Error: Systemctl main enable issue in %s (%s)" % (service, e.unit))
Brad Bishopc342db32019-05-15 21:57:59 -0400328 elif command == "preset-all":
329 if len(args.service) != 0:
330 sys.exit("Too many arguments.")
331 if args.preset_mode != "enable-only":
332 sys.exit("Only enable-only is supported as preset-mode.")
333 preset_all(root)
334 else:
335 raise RuntimeError()
336
337
338if __name__ == '__main__':
339 main()