main
py 491 lines 15.3 KB
Raw
1 from __future__ import annotations
2
3 import os
4 import re
5 import shutil
6 import subprocess
7 from pathlib import Path
8 from typing import Any
9
10 from helpers import files, state_migration, system_packages
11
12
13 PROJECT_ROOT = Path(__file__).resolve().parents[2]
14 PLUGIN_NAME = "_office"
15 STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
16 RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
17 DOCUMENT_STATE_DIR = STATE_DIR / "documents"
18 LEGACY_DOCUMENT_STATE_DIRS = [
19 RETIRED_STATE_DIR / "documents",
20 Path(files.get_abs_path("usr", "state", "_office", "documents")),
21 Path(files.get_abs_path("usr", "state", "office", "documents")),
22 ]
23 RETIRED_WEB_APT_SOURCE_FILE = Path("/etc/apt/sources.list.d/collaboraonline.sources")
24 RETIRED_WEB_APT_KEYRING_FILE = Path("/etc/apt/keyrings/collaboraonline-release-keyring.gpg")
25 RETIRED_WEB_SUPERVISOR_FILE = Path("/etc/supervisor/conf.d/a0_office_collabora.conf")
26 RETIRED_WEB_SUPERVISOR_PROGRAM = "a0_office_collabora"
27 RETIRED_WEB_RUNTIME_DIRS = [
28 Path("/opt/cool"),
29 Path("/opt/collaboraoffice"),
30 Path("/a0/tmp/_office/collabora"),
31 Path("/a0/usr/plugins/_office/collabora"),
32 PROJECT_ROOT / "tmp" / "_office" / "collabora",
33 PROJECT_ROOT / "usr" / "plugins" / "_office" / "collabora",
34 ]
35 RETIRED_WEB_PACKAGES = (
36 "coolwsd",
37 "coolwsd-deprecated",
38 "code-brand",
39 "collaboraoffice",
40 "collaboraoffice-ure",
41 "collaboraofficebasis-calc",
42 "collaboraofficebasis-core",
43 "collaboraofficebasis-draw",
44 "collaboraofficebasis-en-us",
45 "collaboraofficebasis-extension-pdf-import",
46 "collaboraofficebasis-graphicfilter",
47 "collaboraofficebasis-images",
48 "collaboraofficebasis-impress",
49 "collaboraofficebasis-math",
50 "collaboraofficebasis-ooolinguistic",
51 "collaboraofficebasis-ooofonts",
52 "collaboraofficebasis-writer",
53 )
54 RUNTIME_PACKAGES = (
55 "libreoffice-core",
56 "libreoffice-writer",
57 "libreoffice-calc",
58 "libreoffice-impress",
59 "libreoffice-gtk3",
60 "python3-uno",
61 "fonts-dejavu",
62 "fonts-liberation",
63 "fonts-crosextra-caladea",
64 "fonts-crosextra-carlito",
65 "fonts-noto-core",
66 "fonts-noto-cjk",
67 "fonts-noto-color-emoji",
68 )
69 RETIRED_RUNTIME_PACKAGES = (
70 "firefox-esr",
71 )
72 CLEANUP_MARKER = STATE_DIR / "stale-cleanup-v3.done"
73
74
75 def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
76 """Prepare the LibreOffice runtime and remove retired office state.
77
78 The hook is intentionally idempotent: existing dependencies, missing stale
79 files, packages, and processes count as already clean. It is safe to call
80 during startup and self-update.
81 """
82
83 removed: list[str] = []
84 installed: list[str] = []
85 migrated: list[str] = []
86 warnings: list[str] = []
87 errors: list[str] = []
88
89 _migrate_retired_plugin_state(migrated, warnings, errors)
90 _migrate_legacy_document_state(migrated, warnings, errors)
91
92 retired_web_paths = [
93 path
94 for path in [
95 RETIRED_WEB_APT_SOURCE_FILE,
96 RETIRED_WEB_APT_KEYRING_FILE,
97 RETIRED_WEB_SUPERVISOR_FILE,
98 *RETIRED_WEB_RUNTIME_DIRS,
99 ]
100 if path.exists() or path.is_symlink()
101 ]
102 retired_web_packages = _installed_retired_web_packages()
103 cleanup_needed = force or not CLEANUP_MARKER.exists() or bool(retired_web_paths or retired_web_packages)
104
105 if cleanup_needed:
106 _cleanup_retired_web_runtime(
107 removed,
108 errors,
109 retired_web_packages=retired_web_packages,
110 purge_packages=True,
111 )
112
113 try:
114 CLEANUP_MARKER.parent.mkdir(parents=True, exist_ok=True)
115 CLEANUP_MARKER.write_text("ok\n", encoding="utf-8")
116 except Exception as exc:
117 errors.append(f"{CLEANUP_MARKER}: {exc}")
118
119 retired_packages = [
120 package
121 for package in _installed_packages(RETIRED_RUNTIME_PACKAGES)
122 if package not in retired_web_packages
123 ]
124 if retired_packages:
125 _purge_packages(removed, errors, installed_packages=retired_packages)
126
127 _retire_supervisor_program(errors)
128 _ensure_runtime_dependencies(installed, errors)
129 return {
130 "ok": not errors,
131 "skipped": not cleanup_needed,
132 "removed": removed,
133 "installed": installed,
134 "migrated": migrated,
135 "warnings": warnings,
136 "errors": errors,
137 }
138
139
140 def timezone_changed(timezone: str, previous_timezone: str | None = None) -> dict[str, Any]:
141 try:
142 from plugins._desktop.helpers import desktop_session
143
144 return desktop_session.get_manager().sync_timezone(timezone)
145 except Exception as exc:
146 return {
147 "ok": False,
148 "error": str(exc),
149 "timezone": timezone,
150 "previous_timezone": previous_timezone,
151 }
152
153
154 def retire_collabora_web_runtime(force: bool = False) -> dict[str, Any]:
155 """Retire the legacy Collabora web runtime without preparing LibreOffice.
156
157 This is intentionally narrower than cleanup_stale_runtime_state(). Older
158 Docker self-update managers run the checked-out repo's prepare.py before the
159 updated UI starts, so prepare.py can call this fast hook to remove the stale
160 supervisor program left by v1.10 without blocking health checks on desktop
161 package installation.
162 """
163
164 removed: list[str] = []
165 errors: list[str] = []
166 retired_web_paths = [
167 path
168 for path in [
169 RETIRED_WEB_APT_SOURCE_FILE,
170 RETIRED_WEB_APT_KEYRING_FILE,
171 RETIRED_WEB_SUPERVISOR_FILE,
172 *RETIRED_WEB_RUNTIME_DIRS,
173 ]
174 if path.exists() or path.is_symlink()
175 ]
176
177 if force or retired_web_paths:
178 _cleanup_retired_web_runtime(
179 removed,
180 errors,
181 retired_web_packages=[],
182 purge_packages=False,
183 )
184
185 return {
186 "ok": not errors,
187 "skipped": not force and not retired_web_paths,
188 "removed": removed,
189 "errors": errors,
190 }
191
192
193 def _migrate_legacy_document_state(
194 migrated: list[str],
195 warnings: list[str],
196 errors: list[str],
197 ) -> None:
198 legacy_dirs = [
199 path
200 for path in LEGACY_DOCUMENT_STATE_DIRS
201 if path != DOCUMENT_STATE_DIR and path.exists()
202 ]
203 if not legacy_dirs:
204 return
205
206 if DOCUMENT_STATE_DIR.exists():
207 warnings.extend(
208 f"Legacy Office document state left in place because {DOCUMENT_STATE_DIR} already exists: {path}"
209 for path in legacy_dirs
210 )
211 return
212
213 source = legacy_dirs[0]
214 try:
215 DOCUMENT_STATE_DIR.parent.mkdir(parents=True, exist_ok=True)
216 shutil.copytree(source, DOCUMENT_STATE_DIR, symlinks=True)
217 migrated.append(f"{source} -> {DOCUMENT_STATE_DIR}")
218 except Exception as exc:
219 errors.append(f"Office document state migration failed from {source}: {exc}")
220 return
221
222 warnings.extend(
223 f"Additional legacy Office document state left in place after migrating {source}: {path}"
224 for path in legacy_dirs[1:]
225 )
226
227
228 def _migrate_retired_plugin_state(
229 migrated: list[str],
230 warnings: list[str],
231 errors: list[str],
232 ) -> None:
233 state_migration.migrate_retired_state_tree(
234 source=RETIRED_STATE_DIR,
235 destination=STATE_DIR,
236 owner="Office",
237 migrated=migrated,
238 warnings=warnings,
239 errors=errors,
240 )
241
242
243 def _remove_path(path: Path) -> bool:
244 if path.is_symlink() or path.is_file():
245 path.unlink(missing_ok=True)
246 return True
247 if path.exists():
248 try:
249 shutil.rmtree(path)
250 except FileNotFoundError:
251 pass
252 return True
253 return False
254
255
256 def _cleanup_retired_web_runtime(
257 removed: list[str],
258 errors: list[str],
259 *,
260 retired_web_packages: list[str],
261 purge_packages: bool,
262 ) -> None:
263 _stop_supervisor_program(errors)
264 _kill_old_processes(errors)
265
266 for path in [
267 RETIRED_WEB_APT_SOURCE_FILE,
268 RETIRED_WEB_APT_KEYRING_FILE,
269 RETIRED_WEB_SUPERVISOR_FILE,
270 *RETIRED_WEB_RUNTIME_DIRS,
271 ]:
272 try:
273 if _remove_path(path):
274 removed.append(str(path))
275 except Exception as exc:
276 errors.append(f"{path}: {exc}")
277
278 _retire_supervisor_program(errors)
279 if purge_packages:
280 _purge_packages(removed, errors, installed_packages=retired_web_packages)
281
282
283 def _kill_old_processes(errors: list[str]) -> None:
284 if not shutil.which("pkill"):
285 return
286 result = subprocess.run(
287 ["pkill", "-f", "coolwsd"],
288 check=False,
289 text=True,
290 capture_output=True,
291 timeout=8,
292 )
293 if result.returncode not in {0, 1}:
294 errors.append((result.stderr or result.stdout or "pkill coolwsd failed").strip())
295
296
297 def _stop_supervisor_program(errors: list[str]) -> None:
298 if not shutil.which("supervisorctl"):
299 return
300 status = _supervisorctl("status", RETIRED_WEB_SUPERVISOR_PROGRAM)
301 status_output = _supervisor_output(status)
302 if status.returncode != 0:
303 if _supervisor_absent(status_output) or _supervisor_stopped(status_output):
304 return
305 errors.append(status_output or f"supervisorctl status {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
306 return
307
308 if _supervisor_stopped(status_output):
309 return
310
311 stopped = _supervisorctl("stop", RETIRED_WEB_SUPERVISOR_PROGRAM)
312 stopped_output = _supervisor_output(stopped)
313 if stopped.returncode != 0 and not (
314 _supervisor_absent(stopped_output) or _supervisor_stopped(stopped_output)
315 ):
316 errors.append(stopped_output or f"supervisorctl stop {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
317
318
319 def _retire_supervisor_program(errors: list[str]) -> None:
320 if not shutil.which("supervisorctl"):
321 return
322 status = _supervisorctl("status", RETIRED_WEB_SUPERVISOR_PROGRAM)
323 status_output = _supervisor_output(status)
324 if status.returncode != 0:
325 if _supervisor_absent(status_output):
326 return
327 if not _supervisor_stopped(status_output):
328 errors.append(status_output or f"supervisorctl status {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
329 return
330
331 if not _supervisor_stopped(status_output):
332 stopped = _supervisorctl("stop", RETIRED_WEB_SUPERVISOR_PROGRAM)
333 stopped_output = _supervisor_output(stopped)
334 if stopped.returncode != 0 and not (
335 _supervisor_absent(stopped_output) or _supervisor_stopped(stopped_output)
336 ):
337 errors.append(stopped_output or f"supervisorctl stop {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
338 return
339
340 removed = _supervisorctl("remove", RETIRED_WEB_SUPERVISOR_PROGRAM)
341 removed_output = _supervisor_output(removed)
342 if removed.returncode != 0 and not _supervisor_absent(removed_output):
343 errors.append(removed_output or f"supervisorctl remove {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
344 return
345
346 for command in (("reread",), ("update",)):
347 result = _supervisorctl(*command)
348 output = _supervisor_output(result)
349 if result.returncode != 0 and not _supervisor_absent(output):
350 errors.append(output or f"supervisorctl {' '.join(command)} failed")
351
352
353 def _supervisorctl(*args: str) -> subprocess.CompletedProcess[str]:
354 return subprocess.run(
355 ["supervisorctl", *args],
356 check=False,
357 text=True,
358 capture_output=True,
359 timeout=15,
360 )
361
362
363 def _supervisor_output(result: subprocess.CompletedProcess[str]) -> str:
364 return (result.stderr or result.stdout or "").strip()
365
366
367 def _supervisor_absent(output: str) -> bool:
368 normalized = output.lower()
369 return (
370 "no such process" in normalized
371 or "no such group" in normalized
372 or "not running" in normalized
373 or "unix:///var/run/supervisor.sock" in normalized
374 or "connection refused" in normalized
375 or "no such file" in normalized
376 )
377
378
379 def _supervisor_stopped(output: str) -> bool:
380 return bool(re.search(rf"(^|\s){re.escape(RETIRED_WEB_SUPERVISOR_PROGRAM)}\s+STOPPED\b", output))
381
382
383 def _installed_packages(packages: tuple[str, ...]) -> list[str]:
384 if not shutil.which("dpkg-query"):
385 return []
386 return [package for package in packages if _package_installed(package)]
387
388
389 def _installed_retired_web_packages() -> list[str]:
390 packages = [
391 *_installed_packages(RETIRED_WEB_PACKAGES),
392 *_installed_collabora_packages(),
393 ]
394 return list(dict.fromkeys(packages))
395
396
397 def _installed_collabora_packages() -> list[str]:
398 if not shutil.which("dpkg-query"):
399 return []
400
401 result = subprocess.run(
402 ["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\n", "collaboraoffice*"],
403 check=False,
404 text=True,
405 capture_output=True,
406 timeout=15,
407 )
408
409 packages: list[str] = []
410 for line in result.stdout.splitlines():
411 package, _, status = line.partition("\t")
412 if package.startswith("collaboraoffice") and "install ok installed" in status:
413 packages.append(package)
414 return packages
415
416
417 def _purge_packages(
418 removed: list[str],
419 errors: list[str],
420 *,
421 installed_packages: list[str] | None = None,
422 ) -> None:
423 if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
424 return
425 installed = installed_packages if installed_packages is not None else _installed_retired_web_packages()
426 if not installed:
427 return
428 result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180)
429 if result.returncode == 0:
430 removed.extend(installed)
431 return
432 errors.append((result.stderr or result.stdout or "apt-get purge failed").strip())
433
434
435 def _package_installed(package: str) -> bool:
436 result = subprocess.run(
437 ["dpkg-query", "-W", "-f=${Status}", package],
438 check=False,
439 text=True,
440 capture_output=True,
441 timeout=8,
442 )
443 return result.returncode == 0 and "install ok installed" in result.stdout
444
445
446 def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
447 if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
448 return
449 missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
450 if not missing:
451 return
452
453 if not _apt_update(errors):
454 return
455
456 _install_runtime_packages(missing, installed, errors)
457
458
459 def _install_runtime_packages(
460 packages: list[str],
461 installed: list[str],
462 errors: list[str],
463 ) -> bool:
464 result = _run_apt_command(["apt-get", "install", "-y", "--no-install-recommends", *packages], timeout=900)
465 if result.returncode == 0:
466 installed.extend(packages)
467 return True
468 output = (result.stderr or result.stdout or "apt-get install failed").strip()
469 errors.append(output)
470 return False
471
472
473 def _apt_update(errors: list[str]) -> bool:
474 result = _run_apt_command(["apt-get", "update"], timeout=300)
475 if result.returncode == 0:
476 return True
477 errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
478 return False
479
480
481 def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
482 return system_packages.run_apt_with_retries(
483 lambda: subprocess.run(
484 command,
485 check=False,
486 text=True,
487 capture_output=True,
488 timeout=timeout,
489 env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
490 )
491 )