software packages and deploying

This commit is contained in:
2026-09-10 21:54:55 +02:00
parent 0fb18ae4f7
commit 4567df7ebb
73 changed files with 3088 additions and 6 deletions
+357
View File
@@ -55,6 +55,8 @@ from .version import APP_VERSION
from .privacy import merge_privacy_settings, localized_privacy_settings, normalize_category, append_privacy_audit, privacy_audit_tail, PROTECTED_PRIVACY_CATEGORY_KEYS
from .privacy_retention import check_retention_category, delete_retention_category, append_deletion_audit, deletion_audit_tail, IMPLEMENTED_RETENTION_KEYS
from .backup import (BACKUP_DIR, BACKUP_INTERVAL_HOURS, BACKUP_RETENTION_DAYS, backup_path, create_backup, delete_backup, list_backups, restore_backup, store_uploaded_backup, automatic_backup_loop, system_storage_information)
from .setup_analyzer import register_setup_analyzer
from .software_packages import delete_package_storage, human_size, load_package_manifest, package_execution_timeout_seconds, package_summary, update_package_process_control
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
@@ -1008,6 +1010,9 @@ def _require_admin(request: Request) -> None:
raise HTTPException(403, "Diese Funktion ist nur für Administratoren verfügbar.")
register_setup_analyzer(app, templates, _require_admin)
ASSET_ACCESS_SCOPES = {"self", "department", "location", "all"}
@@ -5873,6 +5878,257 @@ async def delete_software_inventory_entries(request: Request, db: Session = Depe
return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_success=" + quote(message), status_code=303)
@app.get("/software/packages")
def software_packages_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
packages = (
db.query(SoftwarePackage)
.filter(SoftwarePackage.package_type == "deployment")
.order_by(func.lower(SoftwarePackage.name), SoftwarePackage.id)
.all()
)
return templates.TemplateResponse(
"software_packages.html",
{
"request": request,
"package_rows": [package_summary(package) for package in packages],
"human_size": human_size,
},
)
@app.get("/software/packages/{package_id}")
def software_package_page(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment":
raise HTTPException(404, _translate_request(request, "software_packages.not_found", "Software package not found."))
try:
manifest = load_package_manifest(package.id)
except Exception as exc:
raise HTTPException(500, _translate_request(request, "software_packages.storage_error_detail", "The package storage could not be read: {error}", error=str(exc))) from exc
candidate_assets = (
_apply_asset_access(db.query(Asset), request)
.order_by(func.lower(Asset.name), Asset.id)
.all()
)
assets = [
asset
for asset in candidate_assets
if asset.mesh_node_id and detect_platform(asset) == "windows"
]
job_count = db.query(SoftwareJob).filter(SoftwareJob.package_id == package.id).count()
return templates.TemplateResponse(
"software_package.html",
{
"request": request,
"package": package,
"manifest": manifest,
"storage_size": package_summary(package)["storage_size"],
"assets": assets,
"human_size": human_size,
"detect_platform": detect_platform,
"job_count": job_count,
},
)
@app.post("/software/packages/{package_id}/process-control")
async def software_package_process_control(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment":
raise HTTPException(404, _translate_request(request, "software_packages.not_found", "Software package not found."))
form = await request.form()
process_names = str(form.get("process_names") or "")
try:
grace_seconds = int(str(form.get("grace_seconds") or "5"))
except ValueError:
grace_seconds = 5
force_close = str(form.get("force_close") or "").strip().lower() in {"1", "true", "yes", "on"}
try:
update_package_process_control(
package.id,
process_names,
grace_seconds=grace_seconds,
force_close=force_close,
)
except Exception as exc:
logger.exception("Could not update process control for software package %s", package.id)
message = _translate_request(
request,
"software_packages.process_control_error",
"Process control could not be saved: {error}",
error=str(exc),
)
return RedirectResponse(
f"/software/packages/{package.id}?toast_error=" + quote(message),
status_code=303,
)
message = _translate_request(
request,
"software_packages.process_control_saved",
"Process control was saved.",
)
return RedirectResponse(
f"/software/packages/{package.id}?toast_success=" + quote(message),
status_code=303,
)
@app.post("/software/packages/{package_id}/delete")
async def software_package_delete(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment":
raise HTTPException(404, _translate_request(request, "software_packages.not_found", "Software package not found."))
form = await request.form()
delete_jobs = str(form.get("delete_jobs") or "").strip().lower() in {"1", "true", "yes", "on"}
job_ids = [row[0] for row in db.query(SoftwareJob.id).filter(SoftwareJob.package_id == package.id).all()]
if job_ids and not delete_jobs:
message = _translate_request(
request,
"software_packages.delete_jobs_required",
"This package is referenced by {count} software jobs. Confirm deletion of the associated jobs first.",
count=len(job_ids),
)
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(message),
status_code=303,
)
package_name = package.name
try:
if job_ids:
db.query(AssetJobState).filter(
AssetJobState.state_key.like(f"software_deployment:{package.id}:%")
).delete(synchronize_session=False)
db.query(JobEvent).filter(JobEvent.job_id.in_(job_ids)).delete(synchronize_session=False)
db.query(SoftwareJob).filter(SoftwareJob.id.in_(job_ids)).delete(synchronize_session=False)
db.query(SoftwarePackageParameter).filter(SoftwarePackageParameter.package_id == package.id).delete(synchronize_session=False)
db.delete(package)
db.commit()
except Exception:
db.rollback()
raise
try:
delete_package_storage(package_id)
except Exception as exc:
logger.warning("Could not remove software package storage for package %s: %s", package_id, exc)
message = _translate_request(
request,
"software_packages.deleted",
'Software package "{package}" was deleted.',
package=package_name,
)
return RedirectResponse(
"/software/packages?toast_success=" + quote(message),
status_code=303,
)
@app.post("/software/packages/{package_id}/run")
async def software_package_run(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment" or not package.enabled:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(request, "software_packages.unavailable", "The software package is not available.")
),
status_code=303,
)
form = await request.form()
action = str(form.get("action") or "install").strip().lower()
if action not in {"install", "uninstall", "reinstall"}:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(request, "software_packages.invalid_action", "The selected software action is not available.")
),
status_code=303,
)
asset_ids: list[int] = []
for value in form.getlist("asset_ids"):
try:
asset_ids.append(int(value))
except (TypeError, ValueError):
continue
asset_ids = list(dict.fromkeys(asset_ids))
if not asset_ids:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(request, "software_packages.no_assets_selected", "No assets were selected.")
),
status_code=303,
)
visible_assets = (
_apply_asset_access(db.query(Asset), request)
.filter(Asset.id.in_(asset_ids))
.all()
)
visible_by_id = {asset.id: asset for asset in visible_assets}
created = 0
skipped = 0
bulk_batch_id = uuid.uuid4().hex
bulk_total = len(asset_ids)
for position, asset_id in enumerate(asset_ids, start=1):
asset = visible_by_id.get(asset_id)
if not asset:
skipped += 1
continue
try:
_create_software_deployment_job(
db,
request,
asset,
package,
action,
creation_mode="bulk" if bulk_total > 1 else "single",
bulk_batch_id=bulk_batch_id if bulk_total > 1 else None,
bulk_position=position if bulk_total > 1 else None,
bulk_total=bulk_total if bulk_total > 1 else None,
)
created += 1
except (ValueError, FileNotFoundError):
skipped += 1
if not created:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(
request,
"software_packages.no_jobs_created",
"No software job could be created; {skipped} assets were skipped.",
skipped=skipped,
)
),
status_code=303,
)
message = _translate_request(
request,
"software_packages.jobs_created",
"Created {created} software jobs; skipped {skipped} assets.",
created=created,
skipped=skipped,
)
return RedirectResponse(
"/software?toast_success=" + quote(message),
status_code=303,
)
@app.get("/software/installations")
def software_installations_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
@@ -6161,6 +6417,107 @@ def _create_definition_job(
return job
def _create_software_deployment_job(
db: Session,
request: Request,
asset: Asset,
package: SoftwarePackage,
action: str,
*,
creation_mode: str = "single",
bulk_batch_id: str | None = None,
bulk_position: int | None = None,
bulk_total: int | None = None,
) -> SoftwareJob:
if not asset.mesh_node_id:
raise ValueError("missing_node_id")
platform = detect_platform(asset)
if platform != "windows":
raise ValueError("platform_mismatch")
if package.package_type != "deployment" or not package.enabled:
raise ValueError("package_unavailable")
normalized_action = str(action or "").strip().lower()
if normalized_action not in {"install", "uninstall", "reinstall"}:
raise ValueError("invalid_action")
manifest = load_package_manifest(package.id)
manifest_platform = str(manifest.get("platform") or "windows").strip().lower()
if manifest_platform not in {"windows", "all"}:
raise ValueError("platform_mismatch")
timeout_seconds = package_execution_timeout_seconds(manifest)
token = secrets.token_urlsafe(32)
job = SoftwareJob(
asset_id=asset.id,
package_id=package.id,
status="created",
job_type="software_deployment",
action=normalized_action,
priority=100,
platform=platform,
parameters={
"package_name": package.name,
"product_name": str(manifest.get("name") or ""),
"product_version": str(manifest.get("version") or ""),
"installer_type": str(manifest.get("installer_type") or ""),
"package_sha256": str((manifest.get("analysis") or {}).get("sha256") or ""),
"_execution_timeout_seconds": timeout_seconds,
**_job_creation_metadata(
creation_mode=creation_mode,
bulk_batch_id=bulk_batch_id,
bulk_position=bulk_position,
bulk_total=bulk_total,
),
},
result_data={},
attempt_count=0,
max_attempts=1,
callback_token_hash=token_hash(token),
callback_expires_at=datetime.utcnow() + timedelta(
seconds=max(300, min(timeout_seconds + 300, 86400))
),
created_by=_changed_by(request),
)
db.add(job)
db.flush()
sync_asset_job_state(db, job, increment_execution=True)
_record_job_event(
db,
job,
"created",
"created",
_translate_request(
request,
"software_packages.job_created",
'Software job "{package}" ({action}) created.',
package=package.name,
action=_translate_request(
request,
f"jobs.action.{normalized_action}",
normalized_action,
),
),
_changed_by(request),
)
db.commit()
db.refresh(job)
configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/")
callback_base = configured or str(request.base_url).rstrip("/")
job_parameters = dict(job.parameters or {})
job_parameters["_callback_base_url"] = callback_base
job.parameters = job_parameters
db.commit()
threading.Thread(
target=execute_job,
args=(job.id, token, callback_base),
daemon=True,
).start()
return job
@app.post("/assets/{asset_id}/jobs/run")
def asset_run_job_definition(asset_id: int, request: Request, definition_id: int = Form(...), db: Session = Depends(get_db)):
_require_admin(request)