software packages and deploying
This commit is contained in:
+357
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user