laster 0.0.2
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import Message
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
@@ -14,10 +15,39 @@ from app.utils.validators import validate_url_host
|
||||
SAFE_FILE_RE = re.compile(r"[^a-zA-Z0-9._+-]+")
|
||||
|
||||
|
||||
def _safe_file_name(url: str) -> str:
|
||||
def _sanitize_file_name(value: str, fallback: str = "package.deb") -> str:
|
||||
name = SAFE_FILE_RE.sub("-", value).strip("-.")
|
||||
return name or fallback
|
||||
|
||||
|
||||
def _safe_url_file_name(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
name = Path(unquote(parsed.path)).name or "package.deb"
|
||||
return SAFE_FILE_RE.sub("-", name).strip("-") or "package.deb"
|
||||
if name == "download":
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
name = "-".join(parts[-3:]) if len(parts) >= 3 else name
|
||||
return _sanitize_file_name(name)
|
||||
|
||||
|
||||
def _content_disposition_file_name(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
message = Message()
|
||||
message["content-disposition"] = value
|
||||
return _sanitize_file_name(message.get_filename() or "", fallback="")
|
||||
|
||||
|
||||
def _response_file_name(url: str, response: httpx.Response) -> str:
|
||||
name = _content_disposition_file_name(response.headers.get("content-disposition", ""))
|
||||
if not name:
|
||||
name = _safe_url_file_name(str(response.url) or url)
|
||||
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type == "application/vnd.debian.binary-package" and not name.lower().endswith(".deb"):
|
||||
name = f"{name}.deb"
|
||||
|
||||
return name
|
||||
|
||||
|
||||
class Downloader:
|
||||
@@ -28,12 +58,12 @@ class Downloader:
|
||||
def download(self, url: str) -> Path:
|
||||
validate_url_host(url, settings.allowed_download_hosts)
|
||||
settings.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = settings.cache_dir / _safe_file_name(url)
|
||||
|
||||
self.repository.add_log(self.task_id, "info", f"Downloading {url}")
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as response:
|
||||
response.raise_for_status()
|
||||
self._validate_response(url, response)
|
||||
destination = settings.cache_dir / _response_file_name(url, response)
|
||||
with destination.open("wb") as handle:
|
||||
for chunk in response.iter_bytes():
|
||||
handle.write(chunk)
|
||||
|
||||
@@ -5,10 +5,49 @@ from pathlib import Path
|
||||
from app.core.command_runner import CommandRunner
|
||||
|
||||
|
||||
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", "install", "-y", str(file_path)])
|
||||
|
||||
@@ -27,4 +66,3 @@ class DebInstaller:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,6 +13,44 @@ class ManifestClient:
|
||||
version_part = quote(version, safe="")
|
||||
url = f"{settings.robot_package_base_url}/api/apps/{app_id_part}/versions/{version_part}/manifest"
|
||||
response = httpx.get(url, follow_redirects=True, timeout=30)
|
||||
response.raise_for_status()
|
||||
if response.is_error:
|
||||
raise RuntimeError(_format_manifest_error(response))
|
||||
return response.json()
|
||||
|
||||
|
||||
def _format_manifest_error(response: httpx.Response) -> str:
|
||||
base_message = f"Manifest request failed with HTTP {response.status_code}: {response.url}"
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
detail = response.text.strip()
|
||||
return f"{base_message}. {detail}" if detail else base_message
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return base_message
|
||||
|
||||
message_parts = []
|
||||
error = str(payload.get("error") or "").strip()
|
||||
detail = str(payload.get("detail") or "").strip()
|
||||
|
||||
if error:
|
||||
message_parts.append(error)
|
||||
if detail:
|
||||
message_parts.append(detail)
|
||||
|
||||
missing_files = payload.get("missingPackageFiles")
|
||||
if isinstance(missing_files, list) and missing_files:
|
||||
descriptions = []
|
||||
for item in missing_files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
package_name = str(item.get("packageName") or item.get("componentId") or "package").strip()
|
||||
version = str(item.get("version") or "").strip()
|
||||
descriptions.append(f"{package_name} {version}".strip())
|
||||
|
||||
if descriptions:
|
||||
message_parts.append(f"Missing package files: {', '.join(descriptions)}")
|
||||
|
||||
return f"{base_message}. {' '.join(message_parts)}" if message_parts else base_message
|
||||
|
||||
@@ -205,6 +205,31 @@ class TaskRunner:
|
||||
)
|
||||
self.repository.add_log(task_id, "info", f"Checksum verified for {component_id}")
|
||||
|
||||
self.repository.update_task_component(task_id, component_id, progress=50, current_step="validating package metadata")
|
||||
deb_metadata = installer.get_deb_metadata(package_path)
|
||||
expected_package_name = component["packageName"]
|
||||
actual_package_name = deb_metadata["package"]
|
||||
if actual_package_name != expected_package_name:
|
||||
raise ValueError(
|
||||
f"Package metadata mismatch for {component_id}: manifest packageName is "
|
||||
f"{expected_package_name}, but .deb Package is {actual_package_name}. "
|
||||
f"Create or update the package in the web server with Package code {actual_package_name}."
|
||||
)
|
||||
|
||||
expected_version = component.get("version") or ""
|
||||
actual_version = deb_metadata["version"]
|
||||
if expected_version and actual_version != expected_version:
|
||||
raise ValueError(
|
||||
f"Package metadata mismatch for {component_id}: manifest version is "
|
||||
f"{expected_version}, but .deb Version is {actual_version}."
|
||||
)
|
||||
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"info",
|
||||
f"Package metadata verified for {actual_package_name} {actual_version}",
|
||||
)
|
||||
|
||||
self.repository.update_task_component(task_id, component_id, progress=60, current_step="installing package")
|
||||
installer.install_deb(package_path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user