laster 0.0.2

This commit is contained in:
2026-05-26 15:43:56 +07:00
parent e2c4881bb7
commit 8ceb1bb1df
24 changed files with 583 additions and 40 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)