183 lines
5.5 KiB
Python
183 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
from app.core.command_runner import CommandError, CommandRunner
|
|
|
|
|
|
APT_DPKG_OPTIONS = [
|
|
"-o",
|
|
"Dpkg::Use-Pty=0",
|
|
"-o",
|
|
"Dpkg::Options::=--force-confdef",
|
|
"-o",
|
|
"Dpkg::Options::=--force-confold",
|
|
]
|
|
|
|
APT_NONINTERACTIVE_ENV = {
|
|
"DEBIAN_FRONTEND": "noninteractive",
|
|
"DEBCONF_NONINTERACTIVE_SEEN": "true",
|
|
"APT_LISTCHANGES_FRONTEND": "none",
|
|
}
|
|
|
|
|
|
def _parse_deb_control_output(output: str) -> dict[str, str]:
|
|
metadata: dict[str, str] = {}
|
|
|
|
for line in output.splitlines():
|
|
key, separator, value = line.partition(":")
|
|
if not separator:
|
|
continue
|
|
|
|
normalized_key = key.strip().lower()
|
|
if normalized_key in {"package", "version", "architecture"}:
|
|
metadata[normalized_key] = value.strip()
|
|
|
|
return metadata
|
|
|
|
|
|
class DebInstaller:
|
|
def __init__(self, command_runner: CommandRunner) -> None:
|
|
self.command_runner = command_runner
|
|
|
|
def get_deb_metadata(self, file_path: Path) -> dict[str, str]:
|
|
result = self.command_runner.run([
|
|
"dpkg-deb",
|
|
"-f",
|
|
str(file_path),
|
|
"Package",
|
|
"Version",
|
|
"Architecture",
|
|
])
|
|
metadata = _parse_deb_control_output(result.stdout)
|
|
missing_fields = [
|
|
field
|
|
for field in ("package", "version", "architecture")
|
|
if not metadata.get(field)
|
|
]
|
|
|
|
if missing_fields:
|
|
raise ValueError(
|
|
"Downloaded .deb is missing metadata fields: "
|
|
f"{', '.join(missing_fields)}"
|
|
)
|
|
|
|
return metadata
|
|
|
|
def install_deb(self, file_path: Path) -> None:
|
|
self.command_runner.run(
|
|
[
|
|
"apt-get",
|
|
*APT_DPKG_OPTIONS,
|
|
"install",
|
|
"--yes",
|
|
str(file_path),
|
|
],
|
|
env=APT_NONINTERACTIVE_ENV,
|
|
)
|
|
|
|
def remove_package(self, package_name: str, purge: bool = False) -> None:
|
|
action = "purge" if purge else "remove"
|
|
self.command_runner.run(
|
|
["apt-get", *APT_DPKG_OPTIONS, action, "--yes", package_name],
|
|
env=APT_NONINTERACTIVE_ENV,
|
|
)
|
|
|
|
def cleanup_after_remove(self, purge: bool = False) -> None:
|
|
autoremove_command = ["apt-get", *APT_DPKG_OPTIONS, "autoremove", "--yes"]
|
|
if purge:
|
|
autoremove_command.append("--purge")
|
|
self.command_runner.run(autoremove_command, env=APT_NONINTERACTIVE_ENV)
|
|
self.command_runner.run(["apt-get", "clean"], env=APT_NONINTERACTIVE_ENV)
|
|
|
|
def get_package_version(self, package_name: str) -> str | None:
|
|
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])
|
|
version = result.stdout.strip()
|
|
return version or None
|
|
|
|
def check_package_installed(self, package_name: str) -> bool:
|
|
try:
|
|
self.get_package_version(package_name)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
class AptInstaller(DebInstaller):
|
|
def update_package_index(self) -> None:
|
|
self.command_runner.run(
|
|
["apt-get", "update"],
|
|
timeout=600,
|
|
env=APT_NONINTERACTIVE_ENV,
|
|
)
|
|
|
|
def install_package(self, package_name: str) -> None:
|
|
self.command_runner.run(
|
|
[
|
|
"apt-get",
|
|
*APT_DPKG_OPTIONS,
|
|
"install",
|
|
"--yes",
|
|
package_name,
|
|
],
|
|
timeout=1200,
|
|
env=APT_NONINTERACTIVE_ENV,
|
|
)
|
|
|
|
def discover_service_units(self, package_name: str) -> list[str]:
|
|
"""Return concrete systemd service units shipped by an installed package.
|
|
|
|
The package name is already syntax-validated by the manifest validator. It
|
|
is passed as a single argv item and no shell expansion is performed.
|
|
Template units are omitted because they cannot be meaningfully checked
|
|
without an instance name.
|
|
"""
|
|
try:
|
|
result = self.command_runner.run(
|
|
["dpkg-query", "-L", package_name],
|
|
timeout=30,
|
|
log_output=False,
|
|
)
|
|
except CommandError:
|
|
return []
|
|
|
|
service_names: set[str] = set()
|
|
for raw_path in result.stdout.splitlines():
|
|
path = raw_path.strip()
|
|
if not path.endswith(".service"):
|
|
continue
|
|
if "/systemd/system/" not in path:
|
|
continue
|
|
|
|
service_name = PurePosixPath(path).name
|
|
if "@" in service_name:
|
|
continue
|
|
service_names.add(service_name)
|
|
|
|
return sorted(service_names)
|
|
|
|
def wait_for_postgresql(
|
|
self,
|
|
attempts: int = 6,
|
|
delay_seconds: float = 2.0,
|
|
) -> None:
|
|
last_error: CommandError | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
self.command_runner.run(["pg_isready", "--timeout=5"], timeout=10)
|
|
return
|
|
except CommandError as error:
|
|
last_error = error
|
|
if attempt < attempts:
|
|
time.sleep(delay_seconds)
|
|
|
|
raise RuntimeError("PostgreSQL did not become ready after installation") from last_error
|
|
|
|
def is_postgresql_ready(self) -> bool:
|
|
try:
|
|
self.command_runner.run(["pg_isready", "--timeout=5"], timeout=10)
|
|
return True
|
|
except Exception:
|
|
return False
|