Files
InstallerRobot/agent/app/core/installer.py
2026-07-20 16:08:36 +07:00

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 constrained by the Agent APT allowlist. It is
still 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