Coverage for src/debputy/plugin/debputy/service_management.py: 82%

163 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-04-07 12:14 +0200

1import collections 

2import dataclasses 

3import os 

4import textwrap 

5from typing import Dict, List, Literal, Iterable, Sequence 

6 

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 

16 

17DPKG_ROOT = '"${DPKG_ROOT}"' 

18EMPTY_DPKG_ROOT_CONDITION = '[ -z "${DPKG_ROOT}" ]' 

19SERVICE_MANAGER_IS_SYSTEMD_CONDITION = "[ -d /run/systemd/system ]" 

20 

21 

22@dataclasses.dataclass(slots=True) 

23class SystemdServiceContext: 

24 had_install_section: bool 

25 

26 

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 

36 

37 

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 ) 

58 

59 

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 

71 

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 

78 

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 

84 

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 

93 

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 

106 

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 ) 

120 

121 if service_def.auto_start_on_install: 121 ↛ 123line 121 didn't jump to line 123, because the condition on line 121 was never false

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 never false

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 ) 

132 

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 never false

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 never false

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 never false

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) 

169 

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 ) 

187 

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 never false

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 ) 

221 

222 

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 never false

225 return v[1:-1] 

226 return v 

227 

228 

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) 

244 

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 never false

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 never false

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 ) 

302 

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 ) 

311 

312 

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 

321 

322 update_rcd_params = ( 

323 "defaults" if service_def.auto_enable_on_install else "defaults-disabled" 

324 ) 

325 

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 ) 

341 

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 ] 

346 

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") 

358 

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 never false

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 ) 

394 

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 ) 

406 

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 ) 

433 

434 

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 

446 

447 service_registry.register_service( 

448 path, 

449 path.name, 

450 )