aim-web2.1.0rc9
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,398 @@
|
||||
"""Report normalization owned by the shipped runbooks, not by the Core API.
|
||||
|
||||
Only deliberately selected public fields leave these functions. Raw registered
|
||||
results, exception text, credentials and command lines are never returned.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def _bool(value):
|
||||
if type(value) is bool: return value
|
||||
if str(value).lower() in ('true','yes','1'): return True
|
||||
if str(value).lower() in ('false','no','0','none',''): return False
|
||||
raise ValueError('Report boolean is not a supported literal')
|
||||
|
||||
|
||||
def epoch_iso_utc(value):
|
||||
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat().replace('+00:00','Z')
|
||||
|
||||
def capabilities(value):
|
||||
names = ('is_dc','is_dhcp_server','is_hyperv_host','has_veeam_vbr','has_veeam_vbo',
|
||||
'has_veeam_em','is_unifi_controller','is_unifi_os_server')
|
||||
return {k:_bool(value.get(k, False)) for k in names}
|
||||
|
||||
|
||||
def disks(value, platform):
|
||||
result=[]
|
||||
if platform == 'windows':
|
||||
# community.windows.win_disk_facts returns disks -> partitions -> volumes.
|
||||
# Report attached volumes rather than PowerShell-session/mapped drives.
|
||||
for disk in value or []:
|
||||
for partition in disk.get('partitions', []) or []:
|
||||
drive_letter=partition.get('drive_letter')
|
||||
for volume in partition.get('volumes', []) or []:
|
||||
total=volume.get('size')
|
||||
free=volume.get('size_remaining')
|
||||
good=isinstance(total, (int, float)) and isinstance(free, (int, float)) and total >= 0 and free >= 0
|
||||
if good:
|
||||
total=int(total); free=int(free); used=max(0,total-free)
|
||||
pct=round(used/total*100,2) if total else 0.0
|
||||
else:
|
||||
used=total=free=pct=None
|
||||
native_path=volume.get('path') or volume.get('object_id') or ''
|
||||
if drive_letter:
|
||||
name=f'{drive_letter}:'
|
||||
mount=f'{drive_letter}:\\'
|
||||
else:
|
||||
name=volume.get('label') or native_path or f"volume-disk{disk.get('number','?')}-part{partition.get('number','?')}"
|
||||
mount=native_path or name
|
||||
result.append(dict(name=str(name),mount=str(mount),filesystem_type=volume.get('type'),
|
||||
used_bytes=used,total_bytes=total,available_bytes=free,used_percent=pct,
|
||||
status='available' if good else 'unavailable'))
|
||||
else:
|
||||
for row in value:
|
||||
total,free=int(row.get('size_total',0)),int(row.get('size_available',0))
|
||||
used=max(0,total-free); good=total>0
|
||||
name,mount,fs=row.get('device',row['mount']),row['mount'],row.get('fstype')
|
||||
if not good: used=total=free=pct=None
|
||||
else:
|
||||
used,total,free=(int(x) if x is not None else None for x in (used,total,free))
|
||||
pct=round(used/total*100,2) if total and used is not None else None
|
||||
result.append(dict(name=name,mount=mount,filesystem_type=fs,used_bytes=used,total_bytes=total,
|
||||
available_bytes=free,used_percent=pct,status='available' if good else 'unavailable'))
|
||||
return {'platform':platform,'filesystems':result}
|
||||
|
||||
|
||||
SERVICE_ERRORS={
|
||||
5:('permission_denied','Access was denied when starting the service.'),
|
||||
1053:('start_timeout','The service did not respond to the start request in time.'),
|
||||
1058:('service_disabled','The service is disabled.'),
|
||||
1060:('service_not_found','The service no longer exists.'),
|
||||
1068:('dependency_failed','A required dependency service could not be started.'),
|
||||
1069:('logon_failed','The service account could not log on.'),
|
||||
}
|
||||
|
||||
def service_error(raw):
|
||||
code=raw.get('native_code',raw.get('error_code'))
|
||||
if type(code) is not int: code=None
|
||||
reason,message=SERVICE_ERRORS.get(code,('start_failed','The service start request failed.'))
|
||||
# A bounded fallback for the existing win_service module; no raw text export.
|
||||
text=str(raw.get('msg',''))[:4096].lower()
|
||||
if code is None:
|
||||
signatures=[('access is denied',5),('access denied',5),('dependency',1068),
|
||||
('disabled',1058),('timed out',1053),('does not exist',1060),('logon failure',1069)]
|
||||
for term,num in signatures:
|
||||
if term in text:
|
||||
reason,message=SERVICE_ERRORS[num]; break
|
||||
return reason,message,code
|
||||
|
||||
|
||||
def services(before, attempts, after, include=(), exclude=(), check=False):
|
||||
stopped=sorted(s['name'] for s in before if s.get('state')=='stopped')
|
||||
eligible=sorted(s['name'] for s in before if s.get('state')=='stopped'
|
||||
and s.get('start_mode') in ('auto','delayed') and (not include or s['name'] in include)
|
||||
and s['name'] not in exclude)
|
||||
actual={}
|
||||
for row in attempts:
|
||||
if row.get('skipped'): continue
|
||||
name=row.get('item',{}).get('name')
|
||||
if name in eligible and not check: actual[name]=row
|
||||
states={s['name']:s.get('state','unknown') for s in after}
|
||||
newly=sorted(n for n in stopped if states.get(n)=='started')
|
||||
still=sorted(n for n in stopped if states.get(n)=='stopped')
|
||||
absent=sorted(n for n in stopped if n not in states)
|
||||
failures=[]
|
||||
for name, row in actual.items():
|
||||
if states.get(name)=='started': continue
|
||||
if row.get('failed'):
|
||||
reason,message,code=service_error(row)
|
||||
elif name not in states:
|
||||
reason,message,code='state_unavailable','Post-start service state could not be observed.',None
|
||||
else:
|
||||
reason,message,code='not_running_after_start','The service is not running at the post-start observation.',None
|
||||
failures.append({'name':name,'reason':reason,'message':message,'native_code':code})
|
||||
return dict(mode='check' if check else 'apply',initially_stopped=stopped,eligible=eligible,
|
||||
attempted=sorted(actual),excluded=sorted(set(stopped)-set(eligible)),newly_running=newly,
|
||||
still_stopped=still,unobserved=absent,failed_to_start=failures,
|
||||
started_count=len(set(newly)&set(actual)),failed_count=len(failures),before_count=len(stopped),
|
||||
after_observed=bool(after) or not before)
|
||||
|
||||
|
||||
def package_snapshot(raw, platform):
|
||||
"""Normalize package_facts (preferred) or legacy textual snapshots."""
|
||||
result={}
|
||||
if isinstance(raw, Mapping):
|
||||
for name, rows in raw.items():
|
||||
if not isinstance(rows, list): continue
|
||||
for row in rows:
|
||||
if not isinstance(row, Mapping): continue
|
||||
arch=str(row.get('arch') or 'unknown')
|
||||
version=str(row.get('version') or '')
|
||||
release=row.get('release')
|
||||
epoch=row.get('epoch')
|
||||
if release not in (None,''):
|
||||
version=f'{version}-{release}'
|
||||
if epoch not in (None,'','0',0):
|
||||
version=f'{epoch}:{version}'
|
||||
result.setdefault((str(name),arch),set()).add(version)
|
||||
return result
|
||||
for line in str(raw).splitlines():
|
||||
columns=line.split('\t')
|
||||
if len(columns)!=4: raise ValueError('Invalid package database record')
|
||||
name,arch,version,status=columns
|
||||
if platform=='debian' and status!='installed': continue
|
||||
result.setdefault((name,arch),set()).add(version)
|
||||
return result
|
||||
|
||||
|
||||
def _patch_reboot_fields(reboot_required, rebooted, preexisting, reboot_enabled, delay_minutes, blocked_reason=None):
|
||||
observed = None if reboot_required is None else bool(reboot_required)
|
||||
performed = bool(rebooted)
|
||||
final_required = False if performed else observed
|
||||
deferred = bool(final_required) and not bool(reboot_enabled)
|
||||
return dict(
|
||||
reboot_required=final_required,
|
||||
reboot_required_before=bool(preexisting),
|
||||
reboot_required_after=final_required,
|
||||
reboot_performed=performed,
|
||||
reboot_deferred=deferred,
|
||||
reboot_delay_minutes=int(delay_minutes),
|
||||
blocked_reason=blocked_reason,
|
||||
)
|
||||
|
||||
|
||||
def _reboot_reasons(value):
|
||||
rows=value if isinstance(value,list) else []
|
||||
out=[]
|
||||
for row in rows:
|
||||
if not isinstance(row,Mapping): continue
|
||||
source=str(row.get('source','unknown'))[:128]
|
||||
desc=str(row.get('description',''))[:512]
|
||||
out.append(dict(source=source,description=desc))
|
||||
return out[:32]
|
||||
|
||||
|
||||
def patch_blocked(platform, check=False, delay_minutes=0, rescan_after_reboot=False, reboot_reasons=()):
|
||||
result=dict(platform=str(platform), mode='check' if check else 'apply',
|
||||
evidence='preflight_reboot_state', complete=False, updates=[], updated_count=0,
|
||||
installed_count=0, removed_count=0, pending=[], failed_updates=[])
|
||||
result.update(_patch_reboot_fields(True, False, True, False, delay_minutes,
|
||||
'preexisting_reboot_required'))
|
||||
if str(platform) == 'windows':
|
||||
result.update(rescan_after_reboot=bool(rescan_after_reboot), patch_cycles=0,
|
||||
continuation_required=True, remaining_updates_known=False,
|
||||
reboot_reasons_before=_reboot_reasons(reboot_reasons))
|
||||
return result
|
||||
|
||||
|
||||
def patch_linux(before, after, platform, check=False, complete=True, reboot_required=None, rebooted=False,
|
||||
preexisting=False, reboot_enabled=True, delay_minutes=0):
|
||||
old,new=package_snapshot(before,platform),package_snapshot(after,platform)
|
||||
updates=[]
|
||||
if not check:
|
||||
for name,arch in sorted(set(old)|set(new)):
|
||||
a,b=old.get((name,arch),set()),new.get((name,arch),set())
|
||||
if a==b: continue
|
||||
updates.append(dict(name=name,identifier=None,architecture=arch,old_versions=sorted(a),new_versions=sorted(b),
|
||||
action='updated' if a and b else 'installed' if b else 'removed',kb=[]))
|
||||
result=dict(platform=platform,mode='check' if check else 'apply',evidence='package_snapshots',complete=bool(complete),
|
||||
updates=updates,updated_count=sum(x['action']=='updated' for x in updates),
|
||||
installed_count=sum(x['action']=='installed' for x in updates),removed_count=sum(x['action']=='removed' for x in updates),
|
||||
pending=[],failed_updates=[])
|
||||
result.update(_patch_reboot_fields(reboot_required, rebooted, preexisting, reboot_enabled, delay_minutes))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def _hresult_u32(code):
|
||||
if type(code) is not int: return None
|
||||
return code & 0xffffffff
|
||||
|
||||
|
||||
WINDOWS_UPDATE_FAILURES={
|
||||
0x80240009:('operation_in_progress','Another conflicting Windows Update operation is in progress.'),
|
||||
0x80240016:('install_not_allowed','Windows Update could not install this update because another installation was active or a mandatory reboot was pending.'),
|
||||
0x80240017:('not_applicable','The update is no longer applicable to this host.'),
|
||||
0x80240019:('exclusive_install_conflict','Windows Update reported an exclusive-install conflict.'),
|
||||
0x8024001B:('self_update_in_progress','The Windows Update Agent is updating itself.'),
|
||||
0x8024001F:('no_connection','Windows Update could not complete because its network connection was unavailable.'),
|
||||
0x80240021:('timeout','Windows Update did not complete before its operation timeout.'),
|
||||
}
|
||||
|
||||
|
||||
def _windows_failure(code):
|
||||
normalized=_hresult_u32(code)
|
||||
reason,message=WINDOWS_UPDATE_FAILURES.get(normalized,('update_failed','Windows Update failed to install this update.'))
|
||||
return normalized,reason,message
|
||||
|
||||
|
||||
def windows_update_result_failed(value):
|
||||
value=value if isinstance(value,Mapping) else {}
|
||||
if value.get('failed') is True: return True
|
||||
if int(value.get('failed_update_count',0) or 0)>0: return True
|
||||
return any(isinstance(row,Mapping) and 'failure_hresult_code' in row for row in value.get('updates',{}).values())
|
||||
|
||||
|
||||
def windows_update_block_reason(value):
|
||||
value=value if isinstance(value,Mapping) else {}
|
||||
for row in value.get('updates',{}).values():
|
||||
if isinstance(row,Mapping) and 'failure_hresult_code' in row:
|
||||
return _windows_failure(row.get('failure_hresult_code'))[1]
|
||||
# ansible_failed_result can carry only a generic module failure. Keep this bounded.
|
||||
return 'update_failed'
|
||||
|
||||
|
||||
def _windows_pending_from_search(value):
|
||||
value=value if isinstance(value,Mapping) else {}
|
||||
pending=[]
|
||||
for ident,row in value.get('updates',{}).items():
|
||||
if not isinstance(row,Mapping): continue
|
||||
pending.append(dict(name=str(row.get('title',ident)),identifier=str(ident),
|
||||
kb=[str(x) for x in row.get('kb',[])]))
|
||||
return pending
|
||||
|
||||
|
||||
def patch_windows_runs(runs, searches=(), check=False, preexisting=False, rebooted=False,
|
||||
reboot_enabled=True, delay_minutes=0, rescan_after_reboot=False,
|
||||
patch_cycles=0, continuation_required=False, remaining_updates_known=False,
|
||||
reboot_deferred=False, reboot_required_after=False, blocked_reason=None,
|
||||
complete_override=True, reboot_reasons_before=()):
|
||||
runs=runs if isinstance(runs,list) else []
|
||||
searches=searches if isinstance(searches,list) else []
|
||||
installed={}; failed={}
|
||||
for entry in runs:
|
||||
if not isinstance(entry,Mapping): continue
|
||||
requested=entry.get('requested',{}) if isinstance(entry.get('requested'),Mapping) else {}
|
||||
value=entry.get('result',{}) if isinstance(entry.get('result'),Mapping) else {}
|
||||
rows=value.get('updates',{}) if isinstance(value.get('updates'),Mapping) else {}
|
||||
if not rows and entry.get('task_failed'):
|
||||
wave=int(entry.get('wave',0) or 0)
|
||||
ident=str(requested.get('identifier') or ('wave-%s' % wave if wave else 'windows-update-wave'))
|
||||
failed[ident]=dict(name=str(requested.get('title') or 'Windows Update patch wave'),identifier=ident,
|
||||
native_code=None,native_code_hex=None,reason='update_failed',
|
||||
message='Windows Update failed before per-update failure details were available.')
|
||||
for ident,row in rows.items():
|
||||
if not isinstance(row,Mapping): continue
|
||||
ident=str(ident); name=str(row.get('title',requested.get('title',ident)))
|
||||
kb=[str(x) for x in row.get('kb',requested.get('kb',[]))]
|
||||
if row.get('installed') is True and not check:
|
||||
installed[ident]=dict(name=name,identifier=ident,architecture=None,old_versions=[],new_versions=[],
|
||||
action='updated',kb=kb)
|
||||
failed.pop(ident,None)
|
||||
if 'failure_hresult_code' in row:
|
||||
code,reason,message=_windows_failure(row.get('failure_hresult_code'))
|
||||
failed[ident]=dict(name=name,identifier=ident,native_code=code,
|
||||
native_code_hex=(f'0x{code:08X}' if code is not None else None),
|
||||
reason=reason,message=message)
|
||||
pending=[]
|
||||
if remaining_updates_known and searches:
|
||||
pending=_windows_pending_from_search(searches[-1])
|
||||
# A final search is authoritative for remaining applicability; do not duplicate
|
||||
# updates that it says are no longer pending.
|
||||
final_required=bool(reboot_required_after)
|
||||
performed=bool(rebooted)
|
||||
deferred=bool(reboot_deferred) or (final_required and not bool(reboot_enabled))
|
||||
complete=bool(complete_override) and not bool(failed)
|
||||
result=dict(platform='windows',mode='check' if check else 'apply',evidence='windows_update_result',
|
||||
complete=complete,updates=list(installed.values()),updated_count=len(installed),installed_count=0,
|
||||
removed_count=0,pending=pending,failed_updates=list(failed.values()),
|
||||
reboot_required=final_required,reboot_required_before=bool(preexisting),
|
||||
reboot_required_after=final_required,reboot_performed=performed,reboot_deferred=deferred,
|
||||
reboot_delay_minutes=int(delay_minutes),blocked_reason=blocked_reason,
|
||||
rescan_after_reboot=bool(rescan_after_reboot),patch_cycles=int(patch_cycles),
|
||||
continuation_required=bool(continuation_required),remaining_updates_known=bool(remaining_updates_known),
|
||||
reboot_reasons_before=_reboot_reasons(reboot_reasons_before))
|
||||
return result
|
||||
|
||||
def cleanup(paths, directory, removed, unifi, unifi_result, enabled=False, check=False):
|
||||
deleted=[r.get('item') for r in removed.get('results',[]) if r.get('changed') and not check]
|
||||
mode='check' if check else 'apply' if enabled else 'preview'
|
||||
state='retained'
|
||||
if unifi=='disabled':
|
||||
state='candidate' if not enabled or check else 'removed' if unifi_result.get('changed') else 'unchanged'
|
||||
return dict(mode=mode,directory=directory,candidates=list(paths),removed=deleted,
|
||||
unifi_configuration=state,complete=True)
|
||||
|
||||
|
||||
_SECRET=re.compile(r'password|passwd|passphrase|secret|token|credential|private.?key|authorization|community',re.I)
|
||||
_COMMAND={'cmd_line','command','command_line','arguments','args','environment','env','passphrase'}
|
||||
|
||||
def checkmk_config(value):
|
||||
"""Read only the named user config; redact secret/command fields before publication."""
|
||||
import yaml
|
||||
path=value['path']
|
||||
if re.split(r'[\\/]',path)[-1].lower()!='check_mk.user.yml':
|
||||
raise ValueError('Only check_mk.user.yml can be published')
|
||||
content=value.get('content','')
|
||||
if len(content.encode('utf-8'))>512*1024:
|
||||
raise ValueError('Checkmk user configuration exceeds report limit')
|
||||
data=yaml.safe_load(content) if value.get('exists') else {}
|
||||
if data is None: data={}
|
||||
if not isinstance(data,dict): raise ValueError('Checkmk configuration must be a mapping')
|
||||
redactions=[]; seen=set(); budget=[0]
|
||||
def walk(item,path,depth=0):
|
||||
budget[0]+=1
|
||||
if depth>18 or budget[0]>50000: raise ValueError('Configuration structure exceeds limit')
|
||||
if isinstance(item,(dict,list)):
|
||||
if id(item) in seen: raise ValueError('Recursive configuration aliases are unsupported')
|
||||
seen.add(id(item))
|
||||
try:
|
||||
if isinstance(item,list): return [walk(x,path+[str(i)],depth+1) for i,x in enumerate(item)]
|
||||
out={}
|
||||
for key,val in item.items():
|
||||
if not isinstance(key,str): key=str(key)
|
||||
here=path+[key]
|
||||
if _SECRET.search(key) or key.lower() in _COMMAND or (path and path[0].lower()=='mrpe' and key.lower() in ('config','entries')):
|
||||
out[key]='[REDACTED]'; redactions.append('.'.join(here)); continue
|
||||
out[key]=walk(val,here,depth+1)
|
||||
return out
|
||||
finally:seen.remove(id(item))
|
||||
if isinstance(item,str):
|
||||
# Do not expose URL userinfo, inline password assignment, PEM key bodies.
|
||||
if re.search(r'://[^/\s]+:[^/\s]+@|(?:password|passwd|token|secret)\s*[=:]|-----BEGIN .*PRIVATE KEY',item,re.I):
|
||||
redactions.append('.'.join(path)); return '[REDACTED]'
|
||||
# Multiline operational strings are represented by spaces, not terminal controls.
|
||||
return ' '.join(item.splitlines())
|
||||
if item is None or type(item) in (bool,int,float): return item
|
||||
return str(item)
|
||||
return dict(path=path,exists=bool(value.get('exists')),size_bytes=int(value.get('size_bytes',0)),
|
||||
last_write_time_utc=value.get('last_write_time_utc'),sections=walk(data,[]),
|
||||
redacted_paths=redactions,comment_preservation='not_in_structured_output')
|
||||
|
||||
|
||||
def checkmk_changes(copies, selected, plugin_update, unifi_result, opposite, mode, check=False):
|
||||
changed_checks=[r['item']['filename'] for r in copies.get('results',[]) if r.get('changed') and isinstance(r.get('item'),Mapping)]
|
||||
removed=[]
|
||||
if opposite.get('changed') and mode in ('os','network'):
|
||||
removed=['check_unifi-controller.sh' if mode=='os' else 'check_unifi-os.sh']
|
||||
changes=[dict(component='check',name=n,action='updated') for n in changed_checks]
|
||||
changes += [dict(component='check',name=n,action='removed') for n in removed]
|
||||
if plugin_update.get('changed'):changes.append(dict(component='section',name='plugins',action='updated'))
|
||||
if unifi_result.get('changed'):changes.append(dict(component='configuration',name='unifi.cfg',action='updated'))
|
||||
return dict(mode='check' if check else 'apply',changed=bool(changes),managed_sections=['plugins'] if plugin_update else [],
|
||||
changes=changes,deployed_checks=[s['filename'] for s in selected],changed_checks=changed_checks,
|
||||
removed_checks=removed if not check else [],unknown_files_policy='untouched_not_enumerated')
|
||||
|
||||
|
||||
def checkmk_state(observed, service_rows, change_report, package_changed=False, check=False):
|
||||
return dict(mode='check' if check else 'apply',installed=observed.get('installed'),version=observed.get('version'),
|
||||
version_source=observed.get('version_source','unavailable'),
|
||||
services=[dict(name=s['name'],state=s.get('state','unknown')) for s in service_rows],
|
||||
package_changed=bool(package_changed),configuration_updated=any(c['component'] in ('section','configuration') for c in change_report['changes']),
|
||||
checks_deployed=change_report['deployed_checks'],changed_checks=change_report['changed_checks'],removed_checks=change_report['removed_checks'])
|
||||
|
||||
|
||||
class FilterModule:
|
||||
def filters(self):
|
||||
return {'aim_epoch_iso_utc':epoch_iso_utc,'aim_report_capabilities':capabilities,'aim_report_disks':disks,'aim_report_services':services,
|
||||
'aim_report_patch_linux':patch_linux,'aim_report_patch_windows_runs':patch_windows_runs,
|
||||
'aim_report_patch_blocked':patch_blocked,
|
||||
'aim_windows_update_result_failed':windows_update_result_failed,
|
||||
'aim_windows_update_block_reason':windows_update_block_reason,
|
||||
'aim_report_cleanup':cleanup,'aim_report_checkmk_config':checkmk_config,
|
||||
'aim_report_checkmk_changes':checkmk_changes,'aim_report_checkmk_state':checkmk_state}
|
||||
Reference in New Issue
Block a user