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