software package profiles implemeted, job deletion optimized

This commit is contained in:
2026-09-26 13:07:23 +02:00
parent 87a5041162
commit 7d314057e4
54 changed files with 3492 additions and 342 deletions
+250 -41
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
import base64
import hashlib
import io
import json
import os
import re
import shutil
import uuid
import zipfile
from pathlib import Path
from typing import Any
@@ -14,7 +17,8 @@ from sqlalchemy.orm import Session
from .models import SoftwarePackage
PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages"))
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", str(DATA_ROOT / "software-packages")))
PACKAGE_ROOT.mkdir(parents=True, exist_ok=True)
@@ -32,6 +36,18 @@ def _safe_member_name(value: str) -> str:
return name
def _safe_relative_member_path(value: str) -> str:
name = str(value or "").replace("\\", "/").strip()
while name.startswith("./"):
name = name[2:]
if not name or name.startswith("/") or re.match(r"^[A-Za-z]:", name):
raise ValueError("invalid relative package member path")
parts = [part for part in name.split("/") if part not in {"", "."}]
if not parts or any(part == ".." for part in parts):
raise ValueError("invalid relative package member path")
return "/".join(parts)
def _safe_package_label(value: str) -> str:
text = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()
return re.sub(r"\s+", " ", text)[:180]
@@ -76,6 +92,15 @@ def write_package_storage(
json.dumps(analysis, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
profile_id = str((analysis or {}).get("profile_id") or "").strip()
if profile_id:
try:
from .analyzer_profiles import export_profile_bundle
profile_dir = temporary / "metadata"
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_dir / "analyzer-profile.amprofile").write_bytes(export_profile_bundle(profile_id))
except Exception:
pass
if target.exists():
shutil.rmtree(target)
temporary.replace(target)
@@ -151,16 +176,21 @@ def human_size(size: int) -> str:
def package_summary(package: SoftwarePackage) -> dict[str, Any]:
manifest: dict[str, Any] = {}
error = ""
storage_size = 0
errors: list[str] = []
try:
manifest = load_package_manifest(package.id)
except Exception as exc:
error = str(exc)
errors.append(str(exc))
try:
storage_size = package_storage_size(package.id)
except Exception as exc:
errors.append(str(exc))
return {
"package": package,
"manifest": manifest,
"storage_size": package_storage_size(package.id),
"storage_error": error,
"storage_size": storage_size,
"storage_error": "; ".join(error for error in errors if error),
}
@@ -756,20 +786,6 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any]
result["install"] = install
arguments = str(install.get("arguments") or "").strip()
if installer_type == "inno" and "greenshot" in identity:
before = arguments
current_user_scope = re.search(r"(?i)(^|\s)/CURRENTUSER(?=\s|$)", arguments) is not None
if not current_user_scope:
if not re.search(r"(?i)(^|\s)/ALLUSERS(?=\s|$)", arguments):
arguments = _append_install_argument(arguments, "/ALLUSERS", r"(^|\s)/ALLUSERS(?=\s|$)")
if not re.search(r"(?i)(^|\s)/DIR=", arguments):
arguments = (arguments + ' /DIR="C:\\Program Files\\Greenshot"').strip()
if arguments != before:
notes.append("greenshot_machine_scope")
install["arguments"] = arguments
if not current_user_scope:
result.setdefault("deployment_profile", "greenshot-machine")
existing_process_control = result.get("process_control")
process_names_configured = isinstance(existing_process_control, dict) and "process_names" in existing_process_control
process_control = result.setdefault("process_control", {})
@@ -777,9 +793,6 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any]
process_control = {}
result["process_control"] = process_control
process_names = normalize_process_names(process_control.get("process_names"))
if "greenshot" in identity and not process_names and not process_names_configured:
process_names = ["Greenshot.exe"]
notes.append("greenshot_process_control")
process_control["process_names"] = process_names
try:
grace_seconds = int(process_control.get("grace_seconds", 5))
@@ -806,15 +819,8 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any]
if not isinstance(post_install, dict):
post_install = {}
result["post_install"] = post_install
if "greenshot" in identity and not start_configured:
post_install["start_application"] = True
notes.append("greenshot_post_install_start")
else:
post_install["start_application"] = _manifest_bool(post_install.get("start_application"), False)
if "greenshot" in identity and not executable_configured:
post_install["executable"] = r"C:\Program Files\Greenshot\Greenshot.exe"
else:
post_install["executable"] = _clean_manifest_text(post_install.get("executable"), 1024)
post_install["start_application"] = _manifest_bool(post_install.get("start_application"), False)
post_install["executable"] = _clean_manifest_text(post_install.get("executable"), 1024)
post_install["arguments"] = _clean_manifest_text(post_install.get("arguments"), 2048)
post_install["only_if_user_logged_on"] = _manifest_bool(post_install.get("only_if_user_logged_on"), True)
post_install["fail_job_on_error"] = _manifest_bool(post_install.get("fail_job_on_error"), False)
@@ -844,12 +850,15 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
install = manifest.get("install") or {}
installer_type = str(manifest.get("installer_type") or "").strip().lower()
installer_file = _safe_member_name(manifest.get("installer_file", ""))
source_mode = str(install.get("source_mode") or "direct").strip().lower()
embedded_installer = ""
if source_mode == "embedded_archive":
embedded_installer = _safe_relative_member_path(install.get("embedded_installer", ""))
arguments = str(install.get("arguments") or "").strip()
success_codes = _int_codes(install.get("success_codes"), [0])
reboot_codes = _int_codes(install.get("reboot_codes"), [])
timeout_seconds = package_execution_timeout_seconds(manifest)
suppress_browser = bool(install.get("suppress_browser"))
greenshot_preset = "greenshot_machine_scope" in notes or str(manifest.get("deployment_profile") or "") == "greenshot-machine"
success = ", ".join(str(code) for code in success_codes)
reboot = ", ".join(str(code) for code in reboot_codes) or "-999999"
@@ -858,17 +867,38 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
"Set-StrictMode -Version Latest",
"",
"$packageDir = $PSScriptRoot",
f"$installer = Join-Path $packageDir {_ps_quote(installer_file)}",
f"$packageSource = Join-Path $packageDir {_ps_quote(installer_file)}",
f"$sourceMode = {_ps_quote(source_mode)}",
f"$embeddedInstaller = {_ps_quote(embedded_installer)}",
"$installer = $packageSource",
"$installerWorkingDirectory = $packageDir",
f"$installerType = {_ps_quote(installer_type)}",
f"$arguments = {_ps_quote(arguments)}",
f"$successCodes = @({success})",
f"$rebootCodes = @({reboot})",
f"$timeoutSeconds = {timeout_seconds}",
"$suppressBrowser = $" + ("true" if suppress_browser else "false"),
"$greenshotPreset = $" + ("true" if greenshot_preset else "false"),
"$innoLog = $null",
"",
"if (-not (Test-Path -LiteralPath $installer)) {",
"if (-not (Test-Path -LiteralPath $packageSource)) {",
"\tWrite-Error \"Package source not found: $packageSource\"",
"\texit 2",
"}",
"",
"if ($sourceMode -eq 'embedded_archive') {",
"\t$payloadDir = Join-Path $packageDir '_embedded_payload'",
"\tif (Test-Path -LiteralPath $payloadDir) { Remove-Item -LiteralPath $payloadDir -Recurse -Force -ErrorAction Stop }",
"\tNew-Item -ItemType Directory -Path $payloadDir -Force | Out-Null",
"\tExpand-Archive -LiteralPath $packageSource -DestinationPath $payloadDir -Force",
"\t$embeddedWindowsPath = $embeddedInstaller.Replace('/', '\\')",
"\t$installer = Join-Path $payloadDir $embeddedWindowsPath",
"\t$installerWorkingDirectory = [System.IO.Path]::GetDirectoryName($installer)",
"\tif ([string]::IsNullOrWhiteSpace($installerWorkingDirectory)) { $installerWorkingDirectory = $payloadDir }",
"\tWrite-Output (\"Embedded payload extracted: \" + $payloadDir)",
"\tWrite-Output (\"Embedded installer selected: \" + $embeddedInstaller)",
"}",
"",
"if (-not (Test-Path -LiteralPath $installer -PathType Leaf)) {",
"\tWrite-Error \"Installer not found: $installer\"",
"\texit 2",
"}",
@@ -882,8 +912,7 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
"",
"Write-Output (\"Installer file: \" + $installer)",
"Write-Output (\"Installer type: \" + $installerType)",
"Write-Output (\"Installer working directory: \" + $packageDir)",
"if ($greenshotPreset) { Write-Output 'Greenshot deployment preset: machine scope, C:\\Program Files\\Greenshot' }",
"Write-Output (\"Installer working directory: \" + $installerWorkingDirectory)",
"",
"$browserPidsBefore = @()",
"if ($suppressBrowser) {",
@@ -897,23 +926,23 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
if installer_type == "msi":
lines += [
"$processArguments = '/i \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow",
]
elif installer_type == "msp":
lines += [
"$processArguments = '/p \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow",
]
elif installer_type == "msu":
lines += [
"$processArguments = '\"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow",
]
elif installer_type in {"msix", "appx"}:
lines += ["Add-AppxPackage -Path $installer -ErrorAction Stop", "exit 0"]
return "\n".join(lines) + "\n"
else:
lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $packageDir -PassThru -NoNewWindow")
lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow")
lines += [
"Write-Output (\"Installer PID: \" + $process.Id)",
@@ -1417,3 +1446,183 @@ def build_deployment_user_script(manifest: dict[str, Any], action: str) -> str:
"Write-JobLog (\"Software deployment completed: $deploymentPackage; action=$deploymentAction; reboot=$rebootRequired\")",
]
return "\n".join(lines)
# v0.5.5.90 portable software-package bundles
PACKAGE_BUNDLE_SCHEMA = "assetmanager-software-package-bundle-v1"
PACKAGE_IMPORT_MAX_MB = max(64, int(os.getenv("SOFTWARE_PACKAGE_IMPORT_MAX_MB", "8192")))
PACKAGE_IMPORT_MAX_FILES = max(100, int(os.getenv("SOFTWARE_PACKAGE_IMPORT_MAX_FILES", "20000")))
def _sha256_path(path: Path) -> str:
import hashlib
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def export_package_bundle(package_id: int, app_version: str = "") -> bytes:
root = package_directory(package_id)
manifest = load_package_manifest(package_id)
if not root.is_dir():
raise FileNotFoundError("package storage not found")
files: dict[str, dict[str, Any]] = {}
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(root).as_posix()
_safe_relative_member_path(rel)
files[rel] = {"sha256": _sha256_path(path), "size": path.stat().st_size}
embedded_profile: bytes | None = None
profile_member = "metadata/analyzer-profile.amprofile"
stored_profile_path = root / profile_member
if stored_profile_path.is_file():
embedded_profile = stored_profile_path.read_bytes()
profile_id = str((manifest.get("analysis") or {}).get("profile_id") or "").strip()
if embedded_profile is None and profile_id:
try:
from .analyzer_profiles import export_profile_bundle
embedded_profile = export_profile_bundle(profile_id)
except Exception:
embedded_profile = None
if embedded_profile is not None:
files[profile_member] = {
"sha256": hashlib.sha256(embedded_profile).hexdigest(),
"size": len(embedded_profile),
}
export_manifest = {
"schema": PACKAGE_BUNDLE_SCHEMA,
"bundle_version": 1,
"application": "AssetManager",
"application_version": str(app_version or ""),
"package_schema": str(manifest.get("schema") or ""),
"name": str(manifest.get("name") or ""),
"version": str(manifest.get("version") or ""),
"profile_id": profile_id,
"files": files,
}
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
archive.writestr("assetmanager-export.json", json.dumps(export_manifest, ensure_ascii=True, indent=2) + "\n")
for rel in files:
if rel == "metadata/analyzer-profile.amprofile":
if embedded_profile is not None:
archive.writestr(rel, embedded_profile)
else:
archive.write(root / rel, arcname=rel)
stream.seek(0)
return stream.read()
def _validate_package_zip_member(info: zipfile.ZipInfo) -> str:
name = str(info.filename or "").replace("\\", "/")
if not name or name.endswith("/"):
return ""
name = _safe_relative_member_path(name)
mode = (info.external_attr >> 16) & 0o170000
if mode == 0o120000:
raise ValueError("symbolic links are not allowed in package bundles")
return name
def import_package_bundle(db: Session, data: bytes | Path, source_name: str = "", import_profile: bool = False) -> SoftwarePackage:
if isinstance(data, Path):
if not data.is_file() or data.stat().st_size <= 0:
raise ValueError("package bundle is empty")
if data.stat().st_size > PACKAGE_IMPORT_MAX_MB * 1024 * 1024:
raise ValueError("package bundle exceeds import size limit")
zip_source: Any = data
else:
if not data:
raise ValueError("package bundle is empty")
if len(data) > PACKAGE_IMPORT_MAX_MB * 1024 * 1024:
raise ValueError("package bundle exceeds import size limit")
zip_source = io.BytesIO(data)
temporary_root = PACKAGE_ROOT / f".import-{uuid.uuid4().hex}.tmp"
temporary_root.mkdir(parents=True, exist_ok=False)
package: SoftwarePackage | None = None
embedded_profile_data: bytes | None = None
try:
with zipfile.ZipFile(zip_source, "r") as archive:
infos = [info for info in archive.infolist() if not info.is_dir()]
if len(infos) > PACKAGE_IMPORT_MAX_FILES:
raise ValueError("package bundle contains too many files")
total = sum(max(0, int(info.file_size)) for info in infos)
if total > PACKAGE_IMPORT_MAX_MB * 1024 * 1024:
raise ValueError("expanded package bundle exceeds import size limit")
members: dict[str, zipfile.ZipInfo] = {}
for info in infos:
name = _validate_package_zip_member(info)
if not name:
continue
if name in members:
raise ValueError("duplicate package bundle member")
members[name] = info
if "package.json" not in members:
raise ValueError("package.json is missing")
export_meta: dict[str, Any] = {}
if "assetmanager-export.json" in members:
export_meta = json.loads(archive.read(members["assetmanager-export.json"]))
if str(export_meta.get("schema") or "") != PACKAGE_BUNDLE_SCHEMA:
raise ValueError("unsupported AssetManager package bundle")
manifest = json.loads(archive.read(members["package.json"]))
manifest, _notes = normalize_package_manifest(manifest)
installer = _safe_member_name(manifest.get("installer_file", ""))
required = {"package.json", installer, _safe_member_name((manifest.get("install") or {}).get("script", "install.ps1")), _safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1"))}
if str((manifest.get("detection") or {}).get("method") or "manual") != "manual":
required.add(_safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1")))
missing = sorted(name for name in required if name not in members)
if missing:
raise ValueError("package bundle is incomplete: " + ", ".join(missing))
checksums = export_meta.get("files") or {}
for name, info in members.items():
if name == "assetmanager-export.json":
continue
content = archive.read(info)
if name == "metadata/analyzer-profile.amprofile":
embedded_profile_data = content
if name in checksums:
expected = str((checksums.get(name) or {}).get("sha256") or "").lower()
if expected and hashlib.sha256(content).hexdigest() != expected:
raise ValueError(f"checksum mismatch for {name}")
if name in checksums:
import hashlib
expected = str((checksums.get(name) or {}).get("sha256") or "").lower()
if expected and hashlib.sha256(content).hexdigest() != expected:
raise ValueError(f"checksum mismatch for {name}")
target = temporary_root / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
package = SoftwarePackage(
name=unique_package_name(db, str(manifest.get("name") or Path(source_name or "Imported package").stem), str(manifest.get("version") or "")),
description=f"Imported AssetManager package | {str(manifest.get('vendor') or '').strip()}".strip(" |"),
package_type="deployment",
enabled=True,
is_system=False,
command_windows="install.ps1",
callback_timeout_minutes=max(5, min(((package_execution_timeout_seconds(manifest) + 59) // 60) + 5, 240)),
)
db.add(package)
db.flush()
manifest["assetmanager_package_id"] = package.id
(temporary_root / "package.json").write_text(json.dumps(manifest, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
(temporary_root / "assetmanager-export.json").unlink(missing_ok=True)
target_root = package_directory(package.id)
if target_root.exists():
shutil.rmtree(target_root)
temporary_root.replace(target_root)
db.commit()
if import_profile and embedded_profile_data:
from .analyzer_profiles import import_profile_bundle
import_profile_bundle(embedded_profile_data, source="community")
return package
except Exception:
db.rollback()
shutil.rmtree(temporary_root, ignore_errors=True)
if package is not None and getattr(package, "id", None):
shutil.rmtree(package_directory(package.id), ignore_errors=True)
raise