aim-web2.1.0rc9

This commit is contained in:
admin_rb
2026-09-22 19:23:17 +02:00
parent d095887d2e
commit 3dfc80b782
438 changed files with 31613 additions and 1510 deletions
+398
View File
@@ -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}