This commit is contained in:
admin_rb
2026-09-15 21:33:10 +02:00
parent 7c24c7ba7f
commit d095887d2e
118 changed files with 1655 additions and 21 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aim-inventory-manager"
version = "2.1.2"
version = "2.2.2"
description = "AIM - Ansible Inventory Manager"
requires-python = ">=3.11"
dependencies = [
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.1.2"
__version__ = "2.2.2"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27 -2
View File
@@ -25,6 +25,11 @@ class Config:
ui_ascii: bool = False
ui_output: str = "compact"
checkmk_agent_base_url: str = ""
checkmk_agent_version: str = ""
checkmk_agent_role_files_dir: Path = Path("/etc/ansible/roles/checkmk_agent/files")
checkmk_monitoring_scripts_dir: Path = Path("/etc/checkmk_monitoring_scripts")
@property
def inventory_dir(self) -> Path:
return self.root_dir / "inventories"
@@ -64,8 +69,19 @@ class Config:
for setting in ("clear_screen", "ascii"):
if setting in ui and not isinstance(ui[setting], bool):
raise ValueError(f"ui.{setting} must be true or false")
return cls(root, service_user, required_group, platform_groups,
ui.get("clear_screen", True), ui.get("ascii", False), output)
maintenance = data.get("maintenance", {}) or {}
if not isinstance(maintenance, dict):
raise ValueError("maintenance must be a mapping")
return cls(
root_dir=root, service_user=service_user, required_group=required_group,
platform_groups=platform_groups,
ui_clear_screen=ui.get("clear_screen", True),
ui_ascii=ui.get("ascii", False), ui_output=output,
checkmk_agent_base_url=str(maintenance.get("checkmk_agent_base_url", "")).strip(),
checkmk_agent_version=str(maintenance.get("checkmk_agent_version", "")).strip(),
checkmk_agent_role_files_dir=Path(str(maintenance.get("checkmk_agent_role_files_dir", "/etc/ansible/roles/checkmk_agent/files"))),
checkmk_monitoring_scripts_dir=Path(str(maintenance.get("checkmk_monitoring_scripts_dir", "/etc/checkmk_monitoring_scripts"))),
)
except Exception as exc:
raise ConfigurationError(f"Could not load {path}: {exc}") from exc
@@ -86,6 +102,15 @@ class Config:
if not isinstance(ui, dict):
raise ConfigurationError("Existing ui configuration is not a mapping")
ui.update(clear_screen=self.ui_clear_screen, ascii=self.ui_ascii, output=self.ui_output)
maintenance = data.setdefault("maintenance", {})
if not isinstance(maintenance, dict):
raise ConfigurationError("Existing maintenance configuration is not a mapping")
maintenance.update(
checkmk_agent_base_url=self.checkmk_agent_base_url,
checkmk_agent_version=self.checkmk_agent_version,
checkmk_agent_role_files_dir=str(self.checkmk_agent_role_files_dir),
checkmk_monitoring_scripts_dir=str(self.checkmk_monitoring_scripts_dir),
)
fd, name = tempfile.mkstemp(prefix=".aim-config-", dir=self.root_dir)
tmp = Path(name)
try:
+1
View File
@@ -0,0 +1 @@
"""Controller-local maintenance helpers."""
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import os
import re
from pathlib import Path
import shutil
import tempfile
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from aim.exceptions import AIMError
@dataclass(frozen=True)
class PackageResult:
label: str
filename: str
source_url: str
size: int
old_sha256: str | None
new_sha256: str
class CheckmkAgentUpdater:
"""Download and atomically stage Checkmk agent packages on the controller."""
def __init__(self, target_dir: Path):
self.target_dir = target_dir
@staticmethod
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
@staticmethod
def _specs(base_url: str, version: str) -> list[tuple[str, str, str]]:
base = base_url.rstrip('/')
return [
('Windows MSI', f'{base}/windows/check_mk_agent.msi', 'check_mk_agent.msi'),
('Debian / Ubuntu DEB', f'{base}/check-mk-agent_{version}_all.deb', 'check-mk-agent.deb'),
('RHEL / Alma / Rocky RPM', f'{base}/check-mk-agent-{version}.noarch.rpm', 'check-mk-agent.rpm'),
]
def update(self, base_url: str, version: str) -> list[PackageResult]:
base_url = base_url.strip().rstrip('/')
version = version.strip()
if not base_url:
raise AIMError('Checkmk agent base URL is required.')
if not version:
raise AIMError('Checkmk agent version is required.')
if not re.fullmatch(r'\d+\.\d+\.\d+p\d+-\d+', version):
raise AIMError(
'Checkmk agent version must use the package format <major>.<minor>.<patch>p<patchlevel>-<revision> '
'(example: 2.4.0p21-1).'
)
if not base_url.startswith(('https://', 'http://')):
raise AIMError('Checkmk agent base URL must start with https:// or http://.')
if not base_url.endswith('/check_mk/agents'):
raise AIMError('Checkmk agent base URL must end with /check_mk/agents.')
specs = self._specs(base_url, version)
downloaded: list[tuple[str, str, str, Path, int, str]] = []
with tempfile.TemporaryDirectory(prefix='aim-checkmk-agent-') as tmp_name:
tmp = Path(tmp_name)
for label, url, filename in specs:
destination = tmp / filename
try:
request = Request(url, headers={'User-Agent': 'bitformer-AIM/CheckmkAgentUpdater'})
with urlopen(request, timeout=60) as response, destination.open('wb') as output:
shutil.copyfileobj(response, output)
except (HTTPError, URLError, TimeoutError, OSError) as exc:
raise AIMError(f'Could not download {label} from {url}: {exc}') from exc
size = destination.stat().st_size
if size <= 0:
raise AIMError(f'Downloaded {label} is empty: {url}')
downloaded.append((label, url, filename, destination, size, self._sha256(destination)))
try:
self.target_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise AIMError(f'Could not create Checkmk role files directory {self.target_dir}: {exc}') from exc
staged: list[tuple[str, str, str, Path, Path, int, str, str | None]] = []
try:
# Stage every file in the destination filesystem before replacing anything.
for label, url, filename, source, size, new_hash in downloaded:
target = self.target_dir / filename
old_hash = self._sha256(target) if target.exists() else None
fd, stage_name = tempfile.mkstemp(prefix=f'.{filename}.aim-', dir=self.target_dir)
stage = Path(stage_name)
try:
with os.fdopen(fd, 'wb') as output, source.open('rb') as input_stream:
shutil.copyfileobj(input_stream, output)
output.flush()
os.fsync(output.fileno())
stage.chmod(0o644)
except Exception:
stage.unlink(missing_ok=True)
raise
staged.append((label, url, filename, stage, target, size, new_hash, old_hash))
results: list[PackageResult] = []
for label, url, filename, stage, target, size, new_hash, old_hash in staged:
os.replace(stage, target)
results.append(PackageResult(label, filename, url, size, old_hash, new_hash))
return results
except OSError as exc:
raise AIMError(f'Could not stage Checkmk agent packages in {self.target_dir}: {exc}') from exc
finally:
for _label, _url, _filename, stage, _target, _size, _new_hash, _old_hash in staged:
stage.unlink(missing_ok=True)
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import subprocess
from aim.exceptions import AIMError
@dataclass(frozen=True)
class RepositoryState:
path: Path
branch: str
upstream: str
head: str
tracked_dirty: bool
untracked_count: int
@dataclass(frozen=True)
class SyncResult:
before: str
after: str
upstream: str
changed: bool
stat: str
class ShadowRepository:
"""Manage a checkout that intentionally mirrors its configured upstream."""
def __init__(self, path: Path):
self.path = path
def _git(self, *args: str, check: bool = True) -> str:
try:
proc = subprocess.run(
['git', '-C', str(self.path), *args],
text=True, capture_output=True, check=False,
)
except OSError as exc:
raise AIMError(f'Could not execute git: {exc}') from exc
output = (proc.stdout or '').strip()
error = (proc.stderr or '').strip()
if check and proc.returncode != 0:
detail = error or output or f'exit code {proc.returncode}'
raise AIMError(f'git {" ".join(args)} failed: {detail}')
return output
def inspect(self) -> RepositoryState:
if not self.path.is_dir():
raise AIMError(f'Monitoring scripts directory does not exist: {self.path}')
if self._git('rev-parse', '--is-inside-work-tree') != 'true':
raise AIMError(f'Not a Git working tree: {self.path}')
branch = self._git('branch', '--show-current') or '(detached HEAD)'
if branch == '(detached HEAD)':
raise AIMError('The monitoring scripts shadow repository is in detached HEAD state.')
upstream = self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
head = self._git('rev-parse', 'HEAD')
tracked = self._git('status', '--porcelain', '--untracked-files=no')
untracked = self._git('ls-files', '--others', '--exclude-standard')
return RepositoryState(self.path, branch, upstream, head, bool(tracked.strip()),
len([line for line in untracked.splitlines() if line.strip()]))
def sync(self) -> SyncResult:
before = self._git('rev-parse', 'HEAD')
upstream = self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
self._git('fetch', '--prune')
remote_head = self._git('rev-parse', '@{u}')
stat = '' if before == remote_head else self._git('diff', '--stat', f'{before}..{remote_head}', check=False)
self._git('reset', '--hard', '@{u}')
after = self._git('rev-parse', 'HEAD')
if after != remote_head:
raise AIMError(f'Repository reset did not reach upstream {upstream}. Expected {remote_head}, got {after}.')
return SyncResult(before, after, upstream, before != after, stat)
+106 -14
View File
@@ -21,22 +21,114 @@ class PlaybookSpec:
PLAYBOOKS: tuple[PlaybookSpec, ...] = (
PlaybookSpec("checkmk_cleanup", "Cleanup CheckMK", "checkmk_cleanup.yml", "CheckMK", ("linux", "windows"), ("checkmk_cleanup_enabled=true",)),
PlaybookSpec("checkmk_install_agent", "Install CheckMK Agent", "checkmk_install_agent.yml", "CheckMK", ("linux", "windows")),
PlaybookSpec("checkmk_update_config", "Update CheckMK Config", "checkmk_update_config.yml", "CheckMK", ("linux", "windows")),
PlaybookSpec("debug_ping", "Ping", "debug_ping.yml", "Debug", ("linux", "windows")),
PlaybookSpec("debug_server_role_selection", "Server Role Selection", "debug_server_role_selection.yml", "Debug", ("linux", "windows")),
PlaybookSpec("debug_disk_usage", "Disk Usage", "debug_disk_usage.yml", "Debug", ("windows",)),
PlaybookSpec("patch_os", "Patch OS", "patch_os.yml", "Maintenance", ("linux", "windows")),
PlaybookSpec("reboot_system", "Reboot System", "reboot_system.yml", "Maintenance", ("linux", "windows")),
PlaybookSpec("backup_eventlog", "Backup Event Log", "backup_eventlog.yml", "Maintenance", ("windows",)),
PlaybookSpec("start_stopped_services", "Start Stopped Services", "start_stopped_services.yml", "Maintenance", ("windows",)),
PlaybookSpec("configure_sophos_initial", "Initial Bitformer Config", "configure_sophos_initial_bitformer_config.yml", "Sophos XGS", ("sophosxgs",), ask_pass=True, require_vault=True),
PlaybookSpec("configure_sophosxgs", "Configure Sophos XGS", "configure_sophosxgs.yml", "Sophos XGS", ("sophosxgs",), customer_specific=True, ask_pass=True, require_vault=True),
# CheckMK
PlaybookSpec(
"checkmk_cleanup",
"Cleanup CheckMK",
"checkmk_cleanup.yml",
"CheckMK",
("linux", "windows"),
("checkmk_cleanup_enabled=true",),
),
PlaybookSpec(
"checkmk_deploy",
"Deploy CheckMK",
"checkmk_deploy.yml",
"CheckMK",
("linux", "windows"),
),
PlaybookSpec(
"checkmk_update_config",
"Update CheckMK Config",
"checkmk_update_config.yml",
"CheckMK",
("windows",),
),
PlaybookSpec(
"checkmk_update_scripts",
"Update CheckMK Scripts",
"checkmk_update_scripts.yml",
"CheckMK",
("linux", "windows"),
),
# Debug
PlaybookSpec(
"debug_ping",
"Ping",
"debug_ping.yml",
"Debug",
("linux", "windows"),
),
PlaybookSpec(
"debug_server_role_selection",
"Server Role Selection",
"debug_server_role_selection.yml",
"Debug",
("linux", "windows"),
),
PlaybookSpec(
"debug_disk_usage",
"Disk Usage",
"debug_disk_usage.yml",
"Debug",
("linux", "windows"),
),
# Maintenance
PlaybookSpec(
"maintenance_patch_os",
"Patch OS",
"maintenance_patch_os.yml",
"Maintenance",
("linux", "windows"),
),
PlaybookSpec(
"maintenance_reboot",
"Reboot System",
"maintenance_reboot.yml",
"Maintenance",
("linux", "windows"),
),
PlaybookSpec(
"maintenance_backup_event_log",
"Backup System Logs",
"maintenance_backup_event_log.yml",
"Maintenance",
("linux", "windows"),
),
PlaybookSpec(
"maintenance_start_stopped_services",
"Start Stopped Services",
"maintenance_start_stopped_services.yml",
"Maintenance",
("linux", "windows"),
),
# Sophos XGS
# PlaybookSpec(
# "configure_sophos_initial",
# "Initial Bitformer Config",
# "configure_sophos_initial_bitformer_config.yml",
# "Sophos XGS",
# ("sophosxgs",),
# ask_pass=True,
# require_vault=True,
# ),
# PlaybookSpec(
# "configure_sophosxgs",
# "Configure Sophos XGS",
# "configure_sophosxgs.yml",
# "Sophos XGS",
# ("sophosxgs",),
# customer_specific=True,
# ask_pass=True,
# require_vault=True,
# ),
)
CATEGORY_ORDER = ("CheckMK", "Debug", "Maintenance", "Sophos XGS")
# CATEGORY_ORDER = ("CheckMK", "Debug", "Maintenance", "Sophos XGS")
CATEGORY_ORDER = ("CheckMK", "Debug", "Maintenance")
class PlaybookManager:
def __init__(self, customers, config):
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23 -1
View File
@@ -235,6 +235,10 @@ class AdministrationScreens:
MenuItem('6', 'Screen clearing', 'Enabled' if draft.ui_clear_screen else 'Disabled'),
MenuItem('7', 'Character style', 'ASCII' if draft.ui_ascii else 'Unicode (terminal-dependent)'),
MenuItem('8', 'Command output', draft.ui_output),
MenuItem('9', 'Checkmk agent base URL', draft.checkmk_agent_base_url or '(not set)'),
MenuItem('10', 'Checkmk agent version', draft.checkmk_agent_version or '(not set)'),
MenuItem('11', 'Checkmk agent role files', str(draft.checkmk_agent_role_files_dir)),
MenuItem('12', 'Checkmk monitoring scripts', str(draft.checkmk_monitoring_scripts_dir)),
])
if choice == '0':
if not dirty or self.confirm('Discard unsaved settings and go back?', default=False):
@@ -264,9 +268,27 @@ class AdministrationScreens:
draft.ui_ascii = not draft.ui_ascii
elif choice == '8':
draft.ui_output = 'live' if draft.ui_output == 'compact' else 'compact'
elif choice == '9':
draft.checkmk_agent_base_url = self.optional_value('Checkmk agent base URL', draft.checkmk_agent_base_url).rstrip('/')
elif choice == '10':
draft.checkmk_agent_version = self.optional_value('Checkmk agent version', draft.checkmk_agent_version)
elif choice == '11':
value = Path(self.required_value('Checkmk agent role files directory', str(draft.checkmk_agent_role_files_dir))).expanduser()
if not value.is_absolute():
raise ValueError('Use an absolute Checkmk agent role files path.')
draft.checkmk_agent_role_files_dir = value
elif choice == '12':
value = Path(self.required_value('Checkmk monitoring scripts directory', str(draft.checkmk_monitoring_scripts_dir))).expanduser()
if not value.is_absolute():
raise ValueError('Use an absolute monitoring scripts path.')
draft.checkmk_monitoring_scripts_dir = value
elif choice == '5':
self.ui.review('Save application settings', [('File', draft.config_path), ('Root directory', draft.root_dir),
('Service account', draft.service_user), ('Authorized group', draft.required_group)],
('Service account', draft.service_user), ('Authorized group', draft.required_group),
('Checkmk agent source', draft.checkmk_agent_base_url or '(not set)'),
('Checkmk agent version', draft.checkmk_agent_version or '(not set)'),
('Checkmk role files', draft.checkmk_agent_role_files_dir),
('Monitoring scripts', draft.checkmk_monitoring_scripts_dir)],
warning='Changing defaults does not migrate existing inventory variables, users or SSH keys.')
if self.confirm('Save these settings?', default=False):
draft.save()
+6 -2
View File
@@ -34,12 +34,13 @@ from aim.ui.access import AccessScreens
from aim.ui.playbooks import PlaybookScreens
from aim.ui.administration import AdministrationScreens
from aim.ui.targets import TargetScreens
from aim.ui.maintenance import MaintenanceScreens
_STYLE = re.compile(r'\[/?(?:bold|dim|red|green|yellow|cyan|magenta)(?: [^\]]+)?\]')
class App(CustomerScreens, HostScreens, AccessScreens, PlaybookScreens,
AdministrationScreens, TargetScreens):
AdministrationScreens, TargetScreens, MaintenanceScreens):
def __init__(self, config: Config | None = None, *, console: Console | None = None,
no_clear: bool = False, plain: bool = False, live_output: bool = False):
self.config = config or Config.load()
@@ -274,7 +275,8 @@ class App(CustomerScreens, HostScreens, AccessScreens, PlaybookScreens,
MenuItem('2', 'Open customer', 'Hosts, access, Vault and playbooks'),
MenuItem('3', 'Browse customers', 'Local inventory overview'),
MenuItem('4', 'Delete customer', 'Remove the complete customer inventory', True),
MenuItem('5', 'Settings', 'Application and display preferences'),
MenuItem('5', 'Maintenance', 'Controller-local Checkmk artifacts and script synchronization'),
MenuItem('6', 'Settings', 'Application and display preferences'),
], back='Exit')
if choice == '0':
return 0
@@ -287,6 +289,8 @@ class App(CustomerScreens, HostScreens, AccessScreens, PlaybookScreens,
elif choice == '4':
self.call(self.delete_customer)
elif choice == '5':
self.call(self.maintenance_menu)
elif choice == '6':
self.call(self.config_menu)
except EOFError:
return 0
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from pathlib import Path
from aim.exceptions import AIMError
from aim.maintenance.checkmk import CheckmkAgentUpdater
from aim.maintenance.repository import ShadowRepository
from aim.ui.components import MenuItem
class MaintenanceScreens:
def maintenance_menu(self) -> None:
while True:
self.clear()
self.header('AIM > Maintenance', 'Controller-local maintenance. No customer inventory is modified.')
choice = self.menu([
MenuItem('1', 'Update Checkmk agent packages', 'Download MSI / DEB / RPM into the checkmk_agent role'),
MenuItem('2', 'Sync Checkmk monitoring scripts', 'Reset the managed shadow checkout to its configured upstream'),
])
if choice == '0':
return
if choice == '1':
self.call(self.update_checkmk_agents)
elif choice == '2':
self.call(self.sync_checkmk_scripts)
def update_checkmk_agents(self) -> None:
self.clear()
self.header('AIM > Maintenance > Checkmk Agent Packages',
'Downloads all package formats first; role files are replaced only after every download succeeds.')
self.ui.details([
('Expected base URL', 'https://<server>/<site>/check_mk/agents'),
('URL ends at', '/check_mk/agents'),
('Example', 'https://monitoring.example.de/monitoring/check_mk/agents'),
('Version format', '<major>.<minor>.<patch>p<patchlevel>-<revision>'),
('Version example', '2.4.0p21-1'),
], title='Checkmk agent source')
base_url = self.required_value('Checkmk agent base URL', self.config.checkmk_agent_base_url or None).rstrip('/')
version = self.required_value('Checkmk agent version', self.config.checkmk_agent_version or None)
target = Path(self.config.checkmk_agent_role_files_dir)
self.ui.review('Update Checkmk agent packages', [
('Base URL', base_url),
('Version', version),
('Target', target),
('Windows source', f'{base_url}/windows/check_mk_agent.msi'),
('Debian source', f'{base_url}/check-mk-agent_{version}_all.deb'),
('RPM source', f'{base_url}/check-mk-agent-{version}.noarch.rpm'),
], warning='Existing role packages are replaced only after all three downloads have completed successfully.')
if not self.confirm('Update these local Checkmk agent packages?', default=False):
return
updater = CheckmkAgentUpdater(target)
self.ui.result('Downloading Checkmk agent packages...', level='info')
results = updater.update(base_url, version)
rows = []
for result in results:
old = result.old_sha256[:12] if result.old_sha256 else '(new file)'
new = result.new_sha256[:12]
rows.append((result.label, f'{result.filename} | {result.size} bytes | {old} -> {new}'))
self.ui.details(rows, title='Updated packages')
self.ui.result('All Checkmk agent packages were updated successfully.', title='Maintenance completed')
if base_url != self.config.checkmk_agent_base_url or version != self.config.checkmk_agent_version:
self.ui.result('Tip: save the base URL/version in Settings to use them as future defaults.', level='info')
self.pause()
def sync_checkmk_scripts(self) -> None:
self.clear()
self.header('AIM > Maintenance > Checkmk Monitoring Scripts',
'Managed shadow checkout: tracked local changes are intentionally discarded; untracked files are retained.')
repo = ShadowRepository(Path(self.config.checkmk_monitoring_scripts_dir))
state = repo.inspect()
self.ui.review('Sync Checkmk monitoring scripts', [
('Repository', state.path),
('Branch', state.branch),
('Upstream', state.upstream),
('Current commit', state.head[:12]),
('Tracked local changes', 'YES - will be discarded' if state.tracked_dirty else 'No'),
('Untracked files', f'{state.untracked_count} (retained)'),
('Operation', 'git fetch --prune; git reset --hard @{u}'),
], warning=('Tracked local modifications will be discarded because this directory is a managed shadow copy.'
if state.tracked_dirty else 'The checkout will be reset exactly to its configured upstream.'))
if not self.confirm('Sync the monitoring scripts shadow copy?', default=False):
return
self.ui.result('Fetching and synchronizing monitoring scripts...', level='info')
result = repo.sync()
self.ui.details([
('Upstream', result.upstream),
('Before', result.before[:12]),
('After', result.after[:12]),
('Result', 'Updated' if result.changed else 'Already current'),
('Changed files', result.stat or 'No upstream file changes'),
], title='Repository sync')
self.ui.result('Checkmk monitoring scripts are synchronized with the configured upstream.', title='Maintenance completed')
self.pause()