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
+168 -10
View File
@@ -440,13 +440,14 @@ def _prepare_remote_directory(
remote_dir: str,
timeout: int,
remote_file: str | None = None,
reset_acl: bool = True,
) -> subprocess.CompletedProcess:
"""Create the job directory and remove a stale target file.
"""Create the job directory and optionally remove one stale target file.
MeshCtrl's Upload action does not overwrite an existing file reliably on
all Windows agents. Failed jobs intentionally retain their directories, so
a repeated dispatch must explicitly remove a previous run.ps1 before the
upload starts.
ACLs are applied to the directory only. Never use a recursive icacls /T
here: directory inheritance flags such as (OI)(CI) must not be rewritten
onto already uploaded files. Doing so can leave files with no effective
ACEs and make them unreadable even for the SYSTEM account.
"""
if platform == 'windows':
command=(
@@ -455,12 +456,51 @@ def _prepare_remote_directory(
)
if remote_file:
command += "Remove-Item -LiteralPath '"+remote_file.replace("'","''")+"' -Force -ErrorAction SilentlyContinue;"
command += "& icacls.exe $p /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C|Out-Null"
if reset_acl:
command += (
"& icacls.exe $p /inheritance:r "
"/grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' "
"/C|Out-Null;"
)
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout)
command=f"mkdir -p '{remote_dir}'"
if remote_file:
command += f" && rm -f -- '{remote_file}'"
command += f" && chmod 700 '{remote_dir}'"
if reset_acl:
command += f" && chmod 700 '{remote_dir}'"
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout)
def _remote_script_preflight(
cfg: dict,
asset: Asset,
password: str,
platform: str,
remote_file: str,
timeout: int,
) -> subprocess.CompletedProcess:
"""Verify that the uploaded job script exists and is readable.
The Windows diagnostic also prints the effective ACL so an upload/ACL
problem is visible in the dispatcher log before PowerShell is launched.
"""
if platform == 'windows':
escaped=remote_file.replace("'","''")
command=(
"$f='"+escaped+"';"
"$exists=Test-Path -LiteralPath $f -PathType Leaf;"
"Write-Output ('File exists: '+$exists);"
"if($exists){"
"try{$i=Get-Item -LiteralPath $f -ErrorAction Stop;Write-Output ('Size: '+$i.Length)}"
"catch{Write-Output ('Size: ERROR - '+$_.Exception.Message)};"
"try{$s=[System.IO.File]::Open($f,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite);$s.Close();Write-Output 'Readable: True'}"
"catch{Write-Output ('Readable: False - '+$_.Exception.Message)};"
"Write-Output 'ACL:'; & icacls.exe $f"
"}"
)
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout)
escaped=remote_file.replace("'","'\''")
command=f"test -f '{escaped}' && test -r '{escaped}' && ls -l '{escaped}'"
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout)
@@ -510,6 +550,94 @@ def _cleanup_remote_directory(cfg: dict, asset: Asset, password: str, platform:
action=['RunCommand','--id',asset.mesh_node_id,'--run',f"rm -rf -- '{remote_dir}'",'--reply']
return _run_meshctrl(cfg,asset,password,action,timeout)
def cleanup_stale_remote_job_directories(
cfg: dict,
asset: Asset,
password: str,
platform: str,
retention_hours: int,
timeout: int,
exclude_directory_names: list[str] | None = None,
) -> subprocess.CompletedProcess:
"""Remove stale AssetManager job-attempt directories on the client.
Windows age is based on directory CreationTimeUtc, not LastWriteTimeUtc.
That represents the age of the job workspace and is not extended when a
script or installer later touches files in that directory. Only numeric
AssetManager job directories (legacy ``<job-id>`` and current
``<job-id>-<attempt>``) are considered. Active/current directory names
supplied by the caller are excluded. Deletion is best-effort.
"""
try:
retention = max(1, min(int(retention_hours), 8760))
except (TypeError, ValueError):
retention = 24
excluded = sorted({str(name or '').strip() for name in (exclude_directory_names or []) if str(name or '').strip()})
if platform == 'windows':
root = r'C:\ProgramData\AssetManager\Jobs'
escaped_root = root.replace("'", "''")
excluded_ps = ','.join("'" + name.replace("'", "''") + "'" for name in excluded)
command = (
f"$root='{escaped_root}';"
f"$cutoff=[DateTime]::UtcNow.AddHours(-{retention});"
f"$excluded=@({excluded_ps});"
"if(Test-Path -LiteralPath $root){"
"$found=0;$eligible=0;$removed=0;$failed=0;$young=0;$active=0;$ignored=0;"
"Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue|ForEach-Object{"
"$item=$_;$found++;"
"if($item.Name -notmatch '^[0-9]+(?:-[0-9]+)?$'){$ignored++;return};"
"if($excluded -contains $item.Name){$active++;Write-Output ('Kept active job directory: '+$item.FullName);return};"
"$created=$item.CreationTimeUtc;"
"if($created -ge $cutoff){$young++;return};"
"$eligible++;$dir=$item.FullName;"
"try{Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction Stop;$removed++;Write-Output ('Removed stale job directory: '+$dir+' created_utc='+$created.ToString('o'))}"
"catch{"
"$firstError=$_.Exception.Message;"
"try{"
"& icacls.exe $dir /inheritance:e /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C /Q | Out-Null;"
"Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction Stop;"
"$removed++;Write-Output ('Removed stale job directory after ACL repair: '+$dir+' first_error='+$firstError)"
"}catch{$failed++;Write-Output ('Failed stale job directory: '+$dir+' - '+$_.Exception.Message+' first_error='+$firstError)}"
"}"
"};"
f"Write-Output ('Stale cleanup summary: found='+$found+' eligible='+$eligible+' removed='+$removed+' failed='+$failed+' young='+$young+' active='+$active+' ignored='+$ignored+' retention_hours={retention}')"
"}else{Write-Output 'Stale cleanup summary: job root not present'}"
)
action = ['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply']
return _run_meshctrl(cfg,asset,password,action,timeout)
root = '/var/lib/assetmanager/jobs'
minutes = retention * 60
exclude_tests = ' '.join(f"! -name '{name}'" for name in excluded)
command = (
f"root='{root}'; "
"if [ -d \"$root\" ]; then "
f"find \"$root\" -mindepth 1 -maxdepth 1 -type d -mmin +{minutes} \\( -name '[0-9]*' -o -name '[0-9]*-[0-9]*' \\) {exclude_tests} -print -exec rm -rf -- {{}} \\;; "
f"echo 'Stale cleanup completed; retention_hours={retention}'; "
"else echo 'Stale cleanup summary: job root not present'; fi"
)
action = ['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply']
return _run_meshctrl(cfg,asset,password,action,timeout)
def _cleanup_stale_remote_job_directories(
cfg: dict,
asset: Asset,
password: str,
platform: str,
current_remote_dir: str,
retention_hours: int,
timeout: int,
) -> subprocess.CompletedProcess:
"""Compatibility wrapper for dispatcher-side cleanup."""
current_name = Path(current_remote_dir.replace('\\', '/')).name
return cleanup_stale_remote_job_directories(
cfg, asset, password, platform, retention_hours, timeout, [current_name] if current_name else []
)
def _add_job_event(db, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None) -> None:
db.add(JobEvent(
@@ -562,7 +690,14 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
sync_asset_job_state(db, job)
db.commit()
cfg=load_config().get('meshcentral',{})
runtime_config=load_config()
cfg=runtime_config.get('meshcentral',{})
software_settings=runtime_config.get('software',{})
remote_cleanup_enabled=bool(software_settings.get('remote_job_cleanup_enabled', True))
try:
remote_job_retention_hours=max(1,min(int(software_settings.get('remote_job_retention_hours',24) or 24),8760))
except (TypeError,ValueError):
remote_job_retention_hours=24
password_env=str(cfg.get('password_env') or 'MESHCENTRAL_PASSWORD')
password=os.getenv(password_env,'')
if not password: raise RuntimeError(f'MeshCentral-Passwortvariable {password_env} ist nicht gesetzt.')
@@ -586,6 +721,8 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
f'Mesh node id: {asset.mesh_node_id}',
f'Interpreter: {interpreter}',
f'Upload required: {upload_required}',
f'Remote stale cleanup enabled: {remote_cleanup_enabled}',
f'Remote job retention: {remote_job_retention_hours} hours',
f'Resolved script characters: {len(payload)}',
f'Resolved script SHA-256: {hashlib.sha256(payload.encode("utf-8")).hexdigest()}',
]
@@ -614,6 +751,16 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n')
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}']
if remote_cleanup_enabled:
stale_cleanup=_cleanup_stale_remote_job_directories(
cfg,asset,password,job.platform,remote_dir,remote_job_retention_hours,min(timeout,120)
)
diagnostics += [
'--- Stale remote job cleanup stdout ---',stale_cleanup.stdout or '',
'--- Stale remote job cleanup stderr ---',stale_cleanup.stderr or '',
f'Stale remote job cleanup return code: {stale_cleanup.returncode}',
]
prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file)
diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}']
if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}')
@@ -626,8 +773,8 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
if not upload_ok:
# MeshCtrl can return code 0 together with "Upload error".
# Recreate the directory, remove any stale target and retry once.
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file)
# Remove only the stale target and retry once. Do not touch ACLs of already uploaded files.
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file,reset_acl=False)
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
upload_results.append(uploaded)
@@ -662,6 +809,7 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
remote_dir,
min(timeout, 120),
remote_package_file,
reset_acl=False,
)
diagnostics += [
f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '',
@@ -687,6 +835,16 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {combined.strip()}'
)
preflight=_remote_script_preflight(cfg,asset,password,job.platform,remote_file,min(timeout,120))
diagnostics += [
'--- Remote script preflight stdout ---', preflight.stdout or '',
'--- Remote script preflight stderr ---', preflight.stderr or '',
f'Remote script preflight return code: {preflight.returncode}',
]
preflight_text=(preflight.stdout or '')+'\n'+(preflight.stderr or '')
if preflight.returncode!=0 or (job.platform=='windows' and ('File exists: True' not in preflight_text or 'Readable: True' not in preflight_text)):
raise RuntimeError('Remote Jobskript ist nach dem Upload nicht lesbar. Siehe Remote script preflight im Dispatcherlog.')
diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False']
started=datetime.utcnow()
result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout)