remove app

This commit is contained in:
2026-06-08 10:54:52 +07:00
parent f9bec78c82
commit 47407cdbca
9 changed files with 112 additions and 13 deletions

View File

@@ -92,7 +92,7 @@ def get_settings() -> Settings:
log_dir=Path(os.getenv("LOG_DIR", "/var/log/local-installer-agent")), log_dir=Path(os.getenv("LOG_DIR", "/var/log/local-installer-agent")),
db_path=Path(os.getenv("DB_PATH", "/var/lib/local-installer-agent/agent.db")), db_path=Path(os.getenv("DB_PATH", "/var/lib/local-installer-agent/agent.db")),
allow_remove=_bool("ALLOW_REMOVE", True), allow_remove=_bool("ALLOW_REMOVE", True),
allow_purge=_bool("ALLOW_PURGE", False), allow_purge=_bool("ALLOW_PURGE", True),
allow_docker=_bool("ALLOW_DOCKER", True), allow_docker=_bool("ALLOW_DOCKER", True),
allow_docker_compose=_bool("ALLOW_DOCKER_COMPOSE", False), allow_docker_compose=_bool("ALLOW_DOCKER_COMPOSE", False),
auto_install_docker=_bool("AUTO_INSTALL_DOCKER", True), auto_install_docker=_bool("AUTO_INSTALL_DOCKER", True),

View File

@@ -140,7 +140,7 @@ class DockerInstaller:
command.append(reference) command.append(reference)
self.command_runner.run(command) self.command_runner.run(command)
def remove_container(self, container_name: str) -> None: def remove_container(self, container_name: str, remove_volumes: bool = False) -> None:
result = self.command_runner.run([ result = self.command_runner.run([
"docker", "docker",
"ps", "ps",
@@ -149,7 +149,34 @@ class DockerInstaller:
f"name=^/{container_name}$", f"name=^/{container_name}$",
]) ])
if result.stdout.strip(): if result.stdout.strip():
self.command_runner.run(["docker", "rm", "-f", container_name]) command = ["docker", "rm", "-f"]
if remove_volumes:
command.append("-v")
command.append(container_name)
self.command_runner.run(command)
def remove_labeled_containers(self, app_id: str, component_id: str, remove_volumes: bool = False) -> None:
result = self.command_runner.run([
"docker",
"ps",
"-aq",
"--filter",
f"label=local-installer-agent.app-id={app_id}",
"--filter",
f"label=local-installer-agent.component-id={component_id}",
])
container_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if not container_ids:
return
command = ["docker", "rm", "-f"]
if remove_volumes:
command.append("-v")
command.extend(container_ids)
self.command_runner.run(command)
def remove_image(self, reference: str) -> None:
self.command_runner.run(["docker", "image", "rm", reference])
def assert_container_running(self, container_name: str) -> None: def assert_container_running(self, container_name: str) -> None:
result = self.command_runner.run([ result = self.command_runner.run([

View File

@@ -83,6 +83,13 @@ class DebInstaller:
env=APT_NONINTERACTIVE_ENV, 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: def get_package_version(self, package_name: str) -> str | None:
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name]) result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])
version = result.stdout.strip() version = result.stdout.strip()

View File

@@ -22,6 +22,9 @@ class ServiceManager:
def restart_service(self, service_name: str) -> None: def restart_service(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "restart", service_name]) self.command_runner.run(["systemctl", "restart", service_name])
def reset_failed(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "reset-failed", service_name])
def get_service_status(self, service_name: str) -> dict[str, object]: def get_service_status(self, service_name: str) -> dict[str, object]:
active = self._query(["systemctl", "is-active", service_name]) == "active" active = self._query(["systemctl", "is-active", service_name]) == "active"
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled" enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"
@@ -37,4 +40,3 @@ class ServiceManager:
return self.command_runner.run(command).stdout.strip() return self.command_runner.run(command).stdout.strip()
except Exception: except Exception:
return "unknown" return "unknown"

View File

@@ -56,10 +56,15 @@ class TaskRunner:
try: try:
if not settings.allow_remove: if not settings.allow_remove:
raise ValueError("Remove is disabled on this Agent") raise ValueError("Remove is disabled on this Agent")
if request.purge and not settings.allow_purge:
raise ValueError("Purge is disabled on this Agent")
self._mark_started(task_id, "starting remove") self._mark_started(task_id, "starting remove")
self._require_root_if_available() self._require_root_if_available()
effective_purge = request.purge and settings.allow_purge
if request.purge and not effective_purge:
self.repository.add_log(
task_id,
"warning",
"Purge cleanup was requested but ALLOW_PURGE is disabled; falling back to remove cleanup",
)
components = self.repository.list_installed_components(request.app_id) components = self.repository.list_installed_components(request.app_id)
if not components and request.package_name: if not components and request.package_name:
@@ -81,6 +86,7 @@ class TaskRunner:
ordered = sorted(components, key=lambda item: item["install_order"], reverse=True) ordered = sorted(components, key=lambda item: item["install_order"], reverse=True)
total = len(ordered) total = len(ordered)
removed_deb_package = False
for index, component in enumerate(ordered, start=1): for index, component in enumerate(ordered, start=1):
progress = int((index - 1) / total * 80) + 10 progress = int((index - 1) / total * 80) + 10
component_id = component["component_id"] component_id = component["component_id"]
@@ -93,22 +99,42 @@ class TaskRunner:
service_name = component.get("service_name") service_name = component.get("service_name")
if service_name: if service_name:
self.repository.add_log(task_id, "info", f"Stopping service {service_name}") self.repository.add_log(task_id, "info", f"Stopping service {service_name}")
services.stop_service(service_name) self._best_effort(task_id, f"stop service {service_name}", lambda: services.stop_service(service_name))
services.disable_service(service_name) self._best_effort(task_id, f"disable service {service_name}", lambda: services.disable_service(service_name))
package_name = component.get("package_name") package_name = component.get("package_name")
if component["type"] == "deb" and package_name: if component["type"] == "deb" and package_name:
self.repository.add_log(task_id, "info", f"Removing package {package_name}") self.repository.add_log(task_id, "info", f"Removing package {package_name}")
installer.remove_package(package_name, purge=request.purge) installer.remove_package(package_name, purge=effective_purge)
self._clean_cached_package_files(task_id, package_name, component_id)
if service_name:
self._best_effort(task_id, f"reset failed state for {service_name}", lambda: services.reset_failed(service_name))
removed_deb_package = True
elif component["type"] == "docker": elif component["type"] == "docker":
container_name = component.get("container_name") or component_id container_name = component.get("container_name") or component_id
self.repository.add_log(task_id, "info", f"Removing Docker container {container_name}") self.repository.add_log(task_id, "info", f"Removing Docker container {container_name}")
docker_installer = DockerInstaller(command_runner) docker_installer = DockerInstaller(command_runner)
docker_installer.ensure_runtime(auto_install=settings.auto_install_docker) docker_installer.ensure_runtime(auto_install=settings.auto_install_docker)
docker_installer.remove_container(container_name) docker_installer.remove_labeled_containers(
request.app_id,
component_id,
remove_volumes=effective_purge,
)
docker_installer.remove_container(container_name, remove_volumes=effective_purge)
image = component.get("docker_image") or component.get("image")
if effective_purge and image:
self._best_effort(task_id, f"remove Docker image {image}", lambda: docker_installer.remove_image(image))
else: else:
raise ValueError(f"Unsupported installed component type: {component['type']}") raise ValueError(f"Unsupported installed component type: {component['type']}")
if removed_deb_package:
self.repository.update_task(task_id, progress=92, current_step="cleaning package leftovers")
self._best_effort(
task_id,
"clean unused packages and apt cache",
lambda: installer.cleanup_after_remove(purge=effective_purge),
)
self.repository.delete_installed_app(request.app_id) self.repository.delete_installed_app(request.app_id)
self.repository.update_task( self.repository.update_task(
task_id, task_id,
@@ -323,3 +349,39 @@ class TaskRunner:
geteuid = getattr(os, "geteuid", None) geteuid = getattr(os, "geteuid", None)
if callable(geteuid) and geteuid() != 0: if callable(geteuid) and geteuid() != 0:
raise PermissionError("Agent must run as root to call apt and systemctl") raise PermissionError("Agent must run as root to call apt and systemctl")
def _best_effort(self, task_id: str, action: str, callback: Any) -> None:
try:
callback()
except Exception as error:
self.repository.add_log(task_id, "warning", f"Could not {action}: {error}")
def _clean_cached_package_files(self, task_id: str, *identifiers: str | None) -> None:
cache_dir = settings.cache_dir
if not cache_dir.exists() or not cache_dir.is_dir():
return
patterns: list[str] = []
seen_patterns: set[str] = set()
for identifier in identifiers:
name = (identifier or "").strip()
if not name:
continue
for pattern in (f"{name}.deb", f"{name}_*.deb"):
if pattern not in seen_patterns:
patterns.append(pattern)
seen_patterns.add(pattern)
removed_files: set[str] = set()
for pattern in patterns:
for file_path in cache_dir.glob(pattern):
if not file_path.is_file():
continue
try:
file_path.unlink()
removed_files.add(str(file_path))
except Exception as error:
self.repository.add_log(task_id, "warning", f"Could not remove cached package {file_path}: {error}")
for file_path in sorted(removed_files):
self.repository.add_log(task_id, "info", f"Removed cached package {file_path}")

View File

@@ -37,6 +37,7 @@ append_csv_env() {
} }
set_agent_env ALLOW_DOCKER true set_agent_env ALLOW_DOCKER true
set_agent_env ALLOW_PURGE true
set_agent_env AUTO_INSTALL_DOCKER true set_agent_env AUTO_INSTALL_DOCKER true
append_csv_env ALLOWED_DOCKER_REGISTRIES docker.io append_csv_env ALLOWED_DOCKER_REGISTRIES docker.io

View File

@@ -66,7 +66,7 @@ APP_DIR=/opt/robot-apps
LOG_DIR=/var/log/local-installer-agent LOG_DIR=/var/log/local-installer-agent
DB_PATH=/var/lib/local-installer-agent/agent.db DB_PATH=/var/lib/local-installer-agent/agent.db
ALLOW_REMOVE=true ALLOW_REMOVE=true
ALLOW_PURGE=false ALLOW_PURGE=true
ALLOW_DOCKER=true ALLOW_DOCKER=true
ALLOW_DOCKER_COMPOSE=false ALLOW_DOCKER_COMPOSE=false
AUTO_INSTALL_DOCKER=true AUTO_INSTALL_DOCKER=true

View File

@@ -483,7 +483,7 @@ function App() {
return; return;
} }
if (action === 'remove' && !window.confirm(`Remove ${app.appName} from ${agentTargetMachine}?`)) { if (action === 'remove' && !window.confirm(`Remove and clean ${app.appName} from ${agentTargetMachine}?`)) {
return; return;
} }

View File

@@ -216,7 +216,7 @@ export async function queueRemove(agentBaseUrl, app) {
timeoutMs: 10000, timeoutMs: 10000,
body: { body: {
appId: app.appId, appId: app.appId,
purge: false purge: true
} }
}); });
} }