software package profiles implemeted, job deletion optimized
This commit is contained in:
+250
-41
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user