Coverage for src/debputy/plugin/debputy/service_management.py: 82%
163 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
1import collections
2import dataclasses
3import os
4import textwrap
5from typing import Dict, List, Literal, Iterable, Sequence
7from debputy.packages import BinaryPackage
8from debputy.plugin.api.spec import (
9 ServiceRegistry,
10 VirtualPath,
11 PackageProcessingContext,
12 BinaryCtrlAccessor,
13 ServiceDefinition,
14)
15from debputy.util import _error, assume_not_none
17DPKG_ROOT = '"${DPKG_ROOT}"'
18EMPTY_DPKG_ROOT_CONDITION = '[ -z "${DPKG_ROOT}" ]'
19SERVICE_MANAGER_IS_SYSTEMD_CONDITION = "[ -d /run/systemd/system ]"
22@dataclasses.dataclass(slots=True)
23class SystemdServiceContext:
24 had_install_section: bool
27@dataclasses.dataclass(slots=True)
28class SystemdUnit:
29 path: VirtualPath
30 names: List[str]
31 type_of_service: str
32 service_scope: str
33 enable_by_default: bool
34 start_by_default: bool
35 had_install_section: bool
38def detect_systemd_service_files(
39 fs_root: VirtualPath,
40 service_registry: ServiceRegistry[SystemdServiceContext],
41 context: PackageProcessingContext,
42) -> None:
43 pkg = context.binary_package
44 systemd_units = _find_and_analyze_systemd_service_files(pkg, fs_root, "system")
45 for unit in systemd_units:
46 service_registry.register_service(
47 unit.path,
48 unit.names,
49 type_of_service=unit.type_of_service,
50 service_scope=unit.service_scope,
51 enable_by_default=unit.enable_by_default,
52 start_by_default=unit.start_by_default,
53 default_upgrade_rule="restart" if unit.start_by_default else "do-nothing",
54 service_context=SystemdServiceContext(
55 unit.had_install_section,
56 ),
57 )
60def generate_snippets_for_systemd_units(
61 services: Sequence[ServiceDefinition[SystemdServiceContext]],
62 ctrl: BinaryCtrlAccessor,
63 _context: PackageProcessingContext,
64) -> None:
65 stop_before_upgrade: List[str] = []
66 stop_then_start_scripts = []
67 on_purge = []
68 start_on_install = []
69 action_on_upgrade = collections.defaultdict(list)
70 assert services
72 for service_def in services:
73 if service_def.auto_enable_on_install:
74 template = """\
75 if deb-systemd-helper debian-installed {UNITFILE}; then
76 # The following line should be removed in trixie or trixie+1
77 deb-systemd-helper unmask {UNITFILE} >/dev/null || true
79 if deb-systemd-helper --quiet was-enabled {UNITFILE}; then
80 # Create new symlinks, if any.
81 deb-systemd-helper enable {UNITFILE} >/dev/null || true
82 fi
83 fi
85 # Update the statefile to add new symlinks (if any), which need to be cleaned
86 # up on purge. Also remove old symlinks.
87 deb-systemd-helper update-state {UNITFILE} >/dev/null || true
88 """
89 else:
90 template = """\
91 # The following line should be removed in trixie or trixie+1
92 deb-systemd-helper unmask {UNITFILE} >/dev/null || true
94 # was-enabled defaults to true, so new installations run enable.
95 if deb-systemd-helper --quiet was-enabled {UNITFILE}; then
96 # Enables the unit on first installation, creates new
97 # symlinks on upgrades if the unit file has changed.
98 deb-systemd-helper enable {UNITFILE} >/dev/null || true
99 else
100 # Update the statefile to add new symlinks (if any), which need to be
101 # cleaned up on purge. Also remove old symlinks.
102 deb-systemd-helper update-state {UNITFILE} >/dev/null || true
103 fi
104 """
105 service_name = service_def.name
107 if assume_not_none(service_def.service_context).had_install_section:
108 ctrl.maintscript.on_configure(
109 template.format(
110 UNITFILE=ctrl.maintscript.escape_shell_words(service_name),
111 )
112 )
113 on_purge.append(service_name)
114 elif service_def.auto_enable_on_install: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 _error(
116 f'The service "{service_name}" cannot be enabled under "systemd" as'
117 f' it has no "[Install]" section. Please correct {service_def.definition_source}'
118 f' so that it does not enable the service or does not apply to "systemd"'
119 )
121 if service_def.auto_start_on_install: 121 ↛ 123line 121 didn't jump to line 123 because the condition on line 121 was always true
122 start_on_install.append(service_name)
123 if service_def.on_upgrade == "stop-then-start": 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 stop_then_start_scripts.append(service_name)
125 elif service_def.on_upgrade in ("restart", "reload"): 125 ↛ 128line 125 didn't jump to line 128 because the condition on line 125 was always true
126 action: str = service_def.on_upgrade
127 action_on_upgrade[action].append(service_name)
128 elif service_def.on_upgrade != "do-nothing":
129 raise AssertionError(
130 f"Missing support for on_upgrade rule: {service_def.on_upgrade}"
131 )
133 if start_on_install or action_on_upgrade: 133 ↛ 170line 133 didn't jump to line 170 because the condition on line 133 was always true
134 lines = [
135 "if {EMPTY_DPKG_ROOT_CONDITION} && {SERVICE_MANAGER_IS_SYSTEMD_CONDITION}; then".format(
136 EMPTY_DPKG_ROOT_CONDITION=EMPTY_DPKG_ROOT_CONDITION,
137 SERVICE_MANAGER_IS_SYSTEMD_CONDITION=SERVICE_MANAGER_IS_SYSTEMD_CONDITION,
138 ),
139 " systemctl --system daemon-reload >/dev/null || true",
140 ]
141 if stop_then_start_scripts: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 unit_files = ctrl.maintscript.escape_shell_words(*stop_then_start_scripts)
143 lines.append(
144 " deb-systemd-invoke start {UNITFILES} >/dev/null || true".format(
145 UNITFILES=unit_files,
146 )
147 )
148 if start_on_install: 148 ↛ 156line 148 didn't jump to line 156 because the condition on line 148 was always true
149 lines.append(' if [ -z "$2" ]; then')
150 lines.append(
151 " deb-systemd-invoke start {UNITFILES} >/dev/null || true".format(
152 UNITFILES=ctrl.maintscript.escape_shell_words(*start_on_install),
153 )
154 )
155 lines.append(" fi")
156 if action_on_upgrade: 156 ↛ 166line 156 didn't jump to line 166 because the condition on line 156 was always true
157 lines.append(' if [ -n "$2" ]; then')
158 for action, units in action_on_upgrade.items():
159 lines.append(
160 " deb-systemd-invoke {ACTION} {UNITFILES} >/dev/null || true".format(
161 ACTION=action,
162 UNITFILES=ctrl.maintscript.escape_shell_words(*units),
163 )
164 )
165 lines.append(" fi")
166 lines.append("fi")
167 combined = "".join(x if x.endswith("\n") else f"{x}\n" for x in lines)
168 ctrl.maintscript.on_configure(combined)
170 if stop_then_start_scripts: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 ctrl.maintscript.unconditionally_in_script(
172 "preinst",
173 textwrap.dedent(
174 """\
175 if {EMPTY_DPKG_ROOT_CONDITION} && [ "$1" = upgrade ] && {SERVICE_MANAGER_IS_SYSTEMD_CONDITION} ; then
176 deb-systemd-invoke stop {UNIT_FILES} >/dev/null || true
177 fi
178 """.format(
179 EMPTY_DPKG_ROOT_CONDITION=EMPTY_DPKG_ROOT_CONDITION,
180 SERVICE_MANAGER_IS_SYSTEMD_CONDITION=SERVICE_MANAGER_IS_SYSTEMD_CONDITION,
181 UNIT_FILES=ctrl.maintscript.escape_shell_words(
182 *stop_then_start_scripts
183 ),
184 )
185 ),
186 )
188 if stop_before_upgrade: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 ctrl.maintscript.on_before_removal(
190 """\
191 if {EMPTY_DPKG_ROOT_CONDITION} && {SERVICE_MANAGER_IS_SYSTEMD_CONDITION} ; then
192 deb-systemd-invoke stop {UNIT_FILES} >/dev/null || true
193 fi
194 """.format(
195 EMPTY_DPKG_ROOT_CONDITION=EMPTY_DPKG_ROOT_CONDITION,
196 SERVICE_MANAGER_IS_SYSTEMD_CONDITION=SERVICE_MANAGER_IS_SYSTEMD_CONDITION,
197 UNIT_FILES=ctrl.maintscript.escape_shell_words(*stop_before_upgrade),
198 )
199 )
200 if on_purge: 200 ↛ 210line 200 didn't jump to line 210 because the condition on line 200 was always true
201 ctrl.maintscript.on_purge(
202 """\
203 if [ -x "/usr/bin/deb-systemd-helper" ]; then
204 deb-systemd-helper purge {UNITFILES} >/dev/null || true
205 fi
206 """.format(
207 UNITFILES=ctrl.maintscript.escape_shell_words(*stop_before_upgrade),
208 )
209 )
210 ctrl.maintscript.on_removed(
211 textwrap.dedent(
212 """\
213 if {SERVICE_MANAGER_IS_SYSTEMD_CONDITION} ; then
214 systemctl --system daemon-reload >/dev/null || true
215 fi
216 """.format(
217 SERVICE_MANAGER_IS_SYSTEMD_CONDITION=SERVICE_MANAGER_IS_SYSTEMD_CONDITION
218 )
219 )
220 )
223def _remove_quote(v: str) -> str:
224 if v and v[0] == v[-1] and v[0] in ('"', "'"): 224 ↛ 226line 224 didn't jump to line 226 because the condition on line 224 was always true
225 return v[1:-1]
226 return v
229def _find_and_analyze_systemd_service_files(
230 pkg: BinaryPackage,
231 fs_root: VirtualPath,
232 systemd_service_dir: Literal["system", "user"],
233) -> Iterable[SystemdUnit]:
234 service_dirs = [
235 f"./usr/lib/systemd/{systemd_service_dir}",
236 f"./lib/systemd/{systemd_service_dir}",
237 ]
238 had_install_sections = set()
239 aliases: Dict[str, List[str]] = collections.defaultdict(list)
240 seen = set()
241 all_files = []
242 expected_units = set()
243 expected_units_required_by = collections.defaultdict(list)
245 for d in service_dirs:
246 system_dir = fs_root.lookup(d)
247 if not system_dir:
248 continue
249 for child in system_dir.iterdir:
250 if child.is_symlink:
251 dest = os.path.basename(child.readlink())
252 aliases[dest].append(child.name)
253 elif child.is_file and child.name not in seen: 253 ↛ 249line 253 didn't jump to line 249 because the condition on line 253 was always true
254 seen.add(child.name)
255 all_files.append(child)
256 if "@" in child.name:
257 # dh_installsystemd does not check the contents of templated services,
258 # and we match that.
259 continue
260 with child.open() as fd:
261 for line in fd:
262 line = line.strip()
263 line_lc = line.lower()
264 if line_lc == "[install]":
265 had_install_sections.add(child.name)
266 elif line_lc.startswith("alias="): 266 ↛ 272line 266 didn't jump to line 272 because the condition on line 266 was always true
267 # This code assumes service names cannot contain spaces (as in
268 # if you copy-paste it for another field it might not work)
269 aliases[child.name].extend(
270 _remove_quote(x) for x in line[6:].split()
271 )
272 elif line_lc.startswith("also="):
273 # This code assumes service names cannot contain spaces (as in
274 # if you copy-paste it for another field it might not work)
275 for unit in (_remove_quote(x) for x in line[5:].split()):
276 expected_units_required_by[unit].append(child.absolute)
277 expected_units.add(unit)
278 for path in all_files:
279 if "@" in path.name:
280 # Match dh_installsystemd, which skips templated services
281 continue
282 names = aliases[path.name]
283 _, type_of_service = path.name.rsplit(".", 1)
284 expected_units.difference_update(names)
285 expected_units.discard(path.name)
286 names.extend(x[:-8] for x in list(names) if x.endswith(".service"))
287 names.insert(0, path.name)
288 if path.name.endswith(".service"):
289 names.insert(1, path.name[:-8])
290 yield SystemdUnit(
291 path,
292 names,
293 type_of_service,
294 systemd_service_dir,
295 # Bug (?) compat with dh_installsystemd. All units are started, but only
296 # enable those with an `[Install]` section.
297 # Possibly related bug #1055599
298 enable_by_default=path.name in had_install_sections,
299 start_by_default=True,
300 had_install_section=path.name in had_install_sections,
301 )
303 if expected_units: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 for unit_name in expected_units:
305 required_by = expected_units_required_by[unit_name]
306 required_names = ", ".join(required_by)
307 _error(
308 f"The unit {unit_name} was required by {required_names} (via Also=...)"
309 f" but was not present in the package {pkg.name}"
310 )
313def generate_snippets_for_init_scripts(
314 services: Sequence[ServiceDefinition[None]],
315 ctrl: BinaryCtrlAccessor,
316 _context: PackageProcessingContext,
317) -> None:
318 for service_def in services:
319 script_name = service_def.path.name
320 script_installed_path = service_def.path.absolute
322 update_rcd_params = (
323 "defaults" if service_def.auto_enable_on_install else "defaults-disabled"
324 )
326 ctrl.maintscript.unconditionally_in_script(
327 "preinst",
328 textwrap.dedent(
329 """\
330 if [ "$1" = "install" ] && [ -n "$2" ] && [ -x {DPKG_ROOT}{SCRIPT_PATH} ] ; then
331 chmod +x {DPKG_ROOT}{SCRIPT_PATH} >/dev/null || true
332 fi
333 """.format(
334 DPKG_ROOT=DPKG_ROOT,
335 SCRIPT_PATH=ctrl.maintscript.escape_shell_words(
336 script_installed_path
337 ),
338 )
339 ),
340 )
342 lines = [
343 "if {EMPTY_DPKG_ROOT_CONDITION} && [ -x {SCRIPT_PATH} ]; then",
344 " update-rc.d {SCRIPT_NAME} {UPDATE_RCD_PARAMS} >/dev/null || exit 1",
345 ]
347 if ( 347 ↛ 359line 347 didn't jump to line 359
348 service_def.auto_start_on_install
349 and service_def.on_upgrade != "stop-then-start"
350 ):
351 lines.append(' if [ -z "$2" ]; then')
352 lines.append(
353 " invoke-rc.d --skip-systemd-native {SCRIPT_NAME} start >/dev/null || exit 1".format(
354 SCRIPT_NAME=ctrl.maintscript.escape_shell_words(script_name),
355 )
356 )
357 lines.append(" fi")
359 if service_def.on_upgrade in ("restart", "reload"): 359 ↛ 368line 359 didn't jump to line 368 because the condition on line 359 was always true
360 lines.append(' if [ -n "$2" ]; then')
361 lines.append(
362 " invoke-rc.d --skip-systemd-native {SCRIPT_NAME} {ACTION} >/dev/null || exit 1".format(
363 SCRIPT_NAME=ctrl.maintscript.escape_shell_words(script_name),
364 ACTION=service_def.on_upgrade,
365 )
366 )
367 lines.append(" fi")
368 elif service_def.on_upgrade == "stop-then-start":
369 lines.append(
370 " invoke-rc.d --skip-systemd-native {SCRIPT_NAME} start >/dev/null || exit 1".format(
371 SCRIPT_NAME=ctrl.maintscript.escape_shell_words(script_name),
372 )
373 )
374 ctrl.maintscript.unconditionally_in_script(
375 "preinst",
376 textwrap.dedent(
377 """\
378 if {EMPTY_DPKG_ROOT_CONDITION} && [ "$1" = "upgrade" ] && [ -x {SCRIPT_PATH} ]; then
379 invoke-rc.d --skip-systemd-native {SCRIPT_NAME} stop > /dev/null || true
380 fi
381 """.format(
382 EMPTY_DPKG_ROOT_CONDITION=EMPTY_DPKG_ROOT_CONDITION,
383 SCRIPT_PATH=ctrl.maintscript.escape_shell_words(
384 script_installed_path
385 ),
386 SCRIPT_NAME=ctrl.maintscript.escape_shell_words(script_name),
387 )
388 ),
389 )
390 elif service_def.on_upgrade != "do-nothing":
391 raise AssertionError(
392 f"Missing support for on_upgrade rule: {service_def.on_upgrade}"
393 )
395 lines.append("fi")
396 combined = "".join(x if x.endswith("\n") else f"{x}\n" for x in lines)
397 ctrl.maintscript.on_configure(
398 combined.format(
399 EMPTY_DPKG_ROOT_CONDITION=EMPTY_DPKG_ROOT_CONDITION,
400 DPKG_ROOT=DPKG_ROOT,
401 UPDATE_RCD_PARAMS=update_rcd_params,
402 SCRIPT_PATH=ctrl.maintscript.escape_shell_words(script_installed_path),
403 SCRIPT_NAME=ctrl.maintscript.escape_shell_words(script_name),
404 )
405 )
407 ctrl.maintscript.on_removed(
408 textwrap.dedent(
409 """\
410 if [ -x {DPKG_ROOT}{SCRIPT_PATH} ]; then
411 chmod -x {DPKG_ROOT}{SCRIPT_PATH} > /dev/null || true
412 fi
413 """.format(
414 DPKG_ROOT=DPKG_ROOT,
415 SCRIPT_PATH=ctrl.maintscript.escape_shell_words(
416 script_installed_path
417 ),
418 )
419 )
420 )
421 ctrl.maintscript.on_purge(
422 textwrap.dedent(
423 """\
424 if {EMPTY_DPKG_ROOT_CONDITION} ; then
425 update-rc.d {SCRIPT_NAME} remove >/dev/null
426 fi
427 """.format(
428 SCRIPT_NAME=ctrl.maintscript.escape_shell_words(script_name),
429 EMPTY_DPKG_ROOT_CONDITION=EMPTY_DPKG_ROOT_CONDITION,
430 )
431 )
432 )
435def detect_sysv_init_service_files(
436 fs_root: VirtualPath,
437 service_registry: ServiceRegistry[None],
438 _context: PackageProcessingContext,
439) -> None:
440 etc_init = fs_root.lookup("/etc/init.d")
441 if not etc_init:
442 return
443 for path in etc_init.iterdir:
444 if path.is_dir or not path.is_executable:
445 continue
447 service_registry.register_service(
448 path,
449 path.name,
450 )