Compare commits

..

4 Commits

Author SHA1 Message Date
admin_rb 3dfc80b782 aim-web2.1.0rc9 2026-09-22 19:23:17 +02:00
admin_rb d095887d2e snapshot 2026-09-15 21:33:10 +02:00
admin_rb 7c24c7ba7f added aim 2026-09-15 18:54:22 +02:00
admin_rb 343fe9b22f added docs 2026-09-15 18:53:28 +02:00
401 changed files with 37408 additions and 0 deletions
View File
+4
View File
@@ -0,0 +1,4 @@
**/bak.yml
**/vault.yml
**/__pycache__
**/tests
+72
View File
@@ -0,0 +1,72 @@
# AIM: Ansible Inventory Manager
**Current candidate: 3.3.0rc4. Canonical Ansible Core: 2.19.11. Service/wire/event API: 1.0.**
AIM is an independent controller product with a built-in terminal (`aim`), a machine
interface (`aimctl`) and an additive Python facade (`aim.services.v1`). Add-ons consume
Core contracts; they do not patch Core, emulate its console or own execution semantics.
## This release
The new `aim_output_v1` publisher convention and catalog-owned schemas expose purposeful
operation data independently of task progress and per-target outcomes. Nine operations
now publish reports: role detection, disk usage, event exports, service recovery, OS
patching, Checkmk cleanup, user-config reading, agent installation and config updating.
Results are finite typed data, not raw Ansible stdout/debug/module dictionaries. Existing
nonreporting operations keep `operation_result: null`. Both summary and detail clients
receive final reports. The terminal retains native output; native Ansible retains its
own execution behavior and does not become a Core API consumer.
3.3.0rc4 makes Windows patching wave-based around the native `ansible.windows.win_updates`
orchestration. AIM submits the currently selected category set as one Windows Update wave
with `reboot: false`, lets the module/WUA process that wave, and evaluates reboot policy only
after the wave returns. A reboot ends the run by default; another post-reboot wave requires
explicit `os_patching_rescan_after_reboot: true`. AIM no longer implements a per-update
scheduler. Per-update result/HRESULT evidence is still normalized into `patch_summary_v1`.
## Install or update
Distribute the complete `AIM-Ansible-3.3.0rc4.zip` and matching `.zip.sha256` from a trusted
channel. A checksum checks integrity, not publisher authenticity. No Git or patch workflow.
```bash
cd /var/tmp
sha256sum -c AIM-Ansible-3.3.0rc4.zip.sha256
unzip AIM-Ansible-3.3.0rc4.zip
cd aim-core-3.3.0rc4
sudo python3 deploy/deploy.py update --dry-run
# Review the plan, then stop active jobs and source writers before applying.
sudo python3 deploy/deploy.py update --apply --quiesced
hash -r
aim --version
aimctl --version
aimctl capabilities
```
The deployer discovers the existing AIM interpreter from its recognized launcher. Only
supply `--aim-python /absolute/venv/bin/python` when discovery needs an explicit known path;
never pass an empty shell variable. Provision AIM's declared dependencies separately for
fresh installation, then use `install` instead of `update`. The deployer does not install
packages, modify services or enable external execution.
Existing `scripts/aim.yml`, inventories, Vaults, keys, environments, add-ons and customer
assets stay. The six explicitly retired Core documents listed in the deployment guide
are removed with recovery copies; unknown operator documents are not purged.
## Canonical documentation
Start at [the documentation index](scripts/docs/README.md). There is one current document
per topic, one current validation record and one current sanity checklist. Historical
changes remain only in [CHANGELOG](scripts/CHANGELOG.md), not competing release guides.
- [Release notes](scripts/docs/RELEASE_NOTES.md) and [validation](scripts/docs/VALIDATION.md)
- [Fresh installation](scripts/docs/INSTALLATION.md), [deployment/recovery](deploy/README.md), and [controller sanity tests](scripts/docs/SANITY.md)
- [Authoritative Core guide](AGENTS.md) and [add-on guide](ADDON_AGENTS.md)
- [API contract](scripts/docs/ADDON_API.md), [operation results](scripts/docs/OPERATION_RESULTS.md), [handoff](scripts/docs/RELEASE_HANDOFF.md)
- [Playbooks](scripts/docs/PLAYBOOKS.md), [Checkmk settings](scripts/docs/CHECKMK.md), [executor staging](scripts/docs/EXECUTOR_STAGING.md)
**Acceptance:** implementation/local tests do not certify this candidate on Windows,
Linux package managers or a deployed service sandbox. Previous controller successes
are historical evidence, not new test passes. External execution is still opt-in and
requires the authorized execution account, collection access and writable staging.
+205
View File
@@ -0,0 +1,205 @@
# AIM core ZIP deployment - 3.3.0rc8
This standard-library operational helper installs a complete source release and
its two source-bound command launchers. No Git, patch files, release manifest,
package installation, add-on inspection or account/group migration is used.
Python 3.11+ on Linux is required. Development validators are not distributed.
## Verify and preview
Obtain the ZIP and its SHA-256 sidecar through a trusted channel. The sidecar is an
integrity check, not a publisher signature. Stage outside the installation tree:
```bash
cd /var/tmp
sha256sum -c AIM-Ansible-3.3.0rc8.zip.sha256
unzip AIM-Ansible-3.3.0rc8.zip
cd aim-core-3.3.0rc8
sudo python3 deploy/deploy.py update --dry-run
```
The target defaults to `/etc/ansible`. `update` requires existing core source;
`install` is for a fresh tree. Preview is the default without `--apply`.
Do not extract over the live installation. Source and target must not overlap;
symlink paths and unexpected source files are rejected.
3.3.0rc8 checks the existing **AIM** Python interpreter before making changes. It can
infer it only from an unambiguous installed `aim` Python shebang. It does not assume
that `sudo python3`, the Ansible interpreter or an add-on environment contains AIM's
dependencies. It preserves a virtual environment's Python path without resolving
its symlink to the system Python.
When discovery is unavailable (for example sudo has a different PATH), provide the
already identified AIM interpreter explicitly. In the operator test session,
`AIM_PYTHON` is the interpreter that successfully ran `scripts/aimctl.py`:
```bash
test -x "$AIM_PYTHON" || { echo 'Set AIM_PYTHON to the existing AIM interpreter first.'; exit 1; }
sudo python3 deploy/deploy.py update --aim-python "$AIM_PYTHON" --dry-run
```
Shell wrappers and `#!/usr/bin/env ...` shebangs are not guessed. The interpreter
must be an absolute executable Python 3.11+ path with no spaces (up to 120 characters),
and must already contain the dependencies from `scripts/pyproject.toml`.
## Command directory and multiple installations
The preview prints the chosen interpreter, command directory, source operations
and `@launchers/aim` / `@launchers/aimctl` operations. `@launchers` is a journal
identifier, not a directory shipped in the source archive.
By default the command directory is the existing `aim` command's parent directory,
or `/usr/local/bin` when no command exists and an interpreter was supplied. Override
with `--bin-dir /absolute/command/directory`. A nondefault `--target` **requires** its
own explicit `--bin-dir` so development deployment cannot silently replace production
commands. Use the same chosen interpreter/directory on preview and apply.
Only recognized AIM Python entry scripts or AIM-managed launchers for this target
may be replaced. Unrelated programs, unsafe symlink command destinations and launchers for
another installation are refused. There is no automatic force-overwrite escape hatch.
Choose a reviewed unused command directory when the existing layout is nonstandard.
Ensure the selected directory is on the intended operator's PATH; aliases and another
installation earlier on PATH remain the operator's responsibility.
## Apply while quiesced
Stop new jobs and exit active AIM/Ansible sessions. `--quiesced` acknowledges that
source writers/runners have been stopped; it does not discover, kill or pause jobs.
```bash
sudo python3 deploy/deploy.py update --apply --quiesced
# Include the same --aim-python and --bin-dir options used in the preview, if any.
hash -r
command -v aim
command -v aimctl
aim --version
aimctl --version
aimctl capabilities
```
The helper verifies the installed `aim --version` and `aimctl capabilities` against
the source release before reporting success. It uses an explicit installed config
path for its capability check. It does not invoke Ansible or contact managed hosts.
Both launchers import the deployed `scripts/src/aim` source with the selected AIM
Python. Old launchers and core source are included in protected recovery data.
A stable deployment lock prevents another cooperating deployment. Each source file
is replaced atomically, preserving existing UID/GID/mode/extended attributes.
New source files use 0644; new launchers use 0755, and recognized existing launchers
retain their metadata with executable bits enabled. Directories use normal caller
ownership/inheritance. This is not a recursive ownership-policy migration.
Recovery defaults to `/var/backups/aim-core`; the helper prints the exact private
0700 recovery directory. `--backup-dir /private/path` selects another root outside
source and installation. Keep sufficient space and apply your retention policy.
Obsolete files inside `scripts/src/aim/` and the explicitly retired Core documents below are pruned. Unknown customer files in
other locations are retained. Add-on code must use its own namespace, not the core
Python namespace.
**Preserved when present:** operator `scripts/aim.yml`, customer `.aim.yml`, inventories,
Vaults, SSH keys, add-ons and their state/configuration, environments, external assets,
unknown playbooks/roles and staged agent files. No dependencies, services, accounts,
LDAP/local group memberships, remote credentials or add-on version gates are modified.
Missing new settings are not automatically inserted into an operator's existing YAML.
## Python package and runtime scope
These launchers are source-bound entry points, not a pip reinstall. The helper does
not change installed wheel/distribution metadata or upgrade packages. Machine clients
calling the installed `aimctl` reach the deployed source. In-process Python clients
must also resolve `aim` to this source (for example an existing editable installation
or an explicitly configured source import path), not an old separately installed
wheel. Verify `aim.__file__` and `aim.__version__` in that client's own environment.
No add-on's Python environment is changed by core deployment.
For a fresh installation, provision an AIM Python 3.11+ environment and its declared
`ruamel.yaml`/`rich` dependencies first, then pass that interpreter to `install`.
Provide the separate canonical Ansible Core **2.19.11** runtime and approved collections
from `requirements-controller.txt` / `requirements.yml` explicitly. The helper does
not install from the network or mutate a system-managed Ansible installation.
For a nondefault controller root, maintain that installation's operator configuration
and use `aimctl --config /absolute/root/scripts/aim.yml ...` when necessary. Existing
terminal configuration discovery is unchanged; creating another launcher does not
silently redirect a terminal's global configuration.
## Opt-in API execution
Follow `scripts/docs/SANITY.md` (historical evidence is in
`scripts/docs/VALIDATION.md`). External execution remains disabled by
default and is not enabled by deployment, readiness or the launcher smoke test.
Preserve the existing YAML and merge only deliberately approved settings:
```yaml
addons:
execution_enabled: true
runtime:
ansible_playbook: /usr/bin/ansible-playbook
```
The executable path is the operator's approved native runtime, not an assumption
for every installation. Worker authorization, filesystem/key access, collections,
connection dependencies and same-UID execution still apply. Do not make private keys
group-readable to bypass an unsupported cross-user deployment.
## Recovery and interruption
Ordinary application/launcher-check failures attempt to restore touched source and
launchers. The update is not a whole-tree atomic transaction: power loss or SIGKILL
can leave partial source. Keep jobs stopped until recovery/version checks complete.
Recovery journals contain intent, hashes and original metadata; they are local
recovery records, not a distributed release manifest or inventory backup.
Use this 3.3.0rc8 deployer and the printed recovery directory; include the same target
for a nondefault installation. Launcher paths are recorded in the journal.
```bash
sudo python3 deploy/deploy.py rollback --from-backup /var/backups/aim-core/RECOVERY-DIRECTORY --dry-run
sudo python3 deploy/deploy.py rollback --from-backup /var/backups/aim-core/RECOVERY-DIRECTORY --apply --quiesced
hash -r
aim --version
```
Rollback validates installed/recovery hashes and refuses to overwrite subsequently
modified source or launchers. Existing aim.yml is never rolled back or removed,
even after a fresh install. Empty directories may remain. A newly created aimctl
launcher is removed when restoring a previous release that had no such launcher.
No remote Ansible action, dependency installation, account/group change or add-on
state is reversed. Keep independent backups of runtime/customer data.
## Required executor staging (independently provisioned units)
Read `scripts/docs/EXECUTOR_STAGING.md` in the installed tree. New add-on executor
installers should provision owner-only staging and a narrow directory write
exception by default, then run `aimctl staging-check` inside the actual unit at
startup. Ordinary `sudo -u` success does not reproduce mount/syscall restrictions.
The source deployer does not inspect/edit/restart services. It preserves the
working exception already applied by the operator. The automatic Core preflight
is on by default, but cannot make a read-only mount writable. Do not remove the
exception, broaden home write access or recursively chown anything during this
update. The new startup command is available after the normal launcher refresh.
## Documentation consolidation in 3.3.0rc8
Current docs have stable topic names with one validation record and one acceptance
checklist. Upgrade removes only these explicitly retired Core filenames (with
protected rollback copies), including locally modified versions of those exact files:
- scripts/docs/RC19_HANDOFF.md
- scripts/docs/SANITY_3.2.0.md
- scripts/docs/SANITY_3.2.1rc2.md
- scripts/docs/VERIFICATION.md
- scripts/docs/LOCAL_VALIDATION.md
- scripts/docs/CONTROLLER_ACCEPTANCE.md
Review REMOVE entries before applying. Move any operator notes out of these retired
Core-owned names before deployment. Unknown Markdown files and other operator
documents are preserved. Rollback restores retired document bytes/metadata through
the existing recovery journal. No recursive docs purge or runtime deletion occurs.
The new playbooks/filter_plugins/*.py files and playbooks/schemas/*.yml files are
Core-owned reporting source. They are deployed with the playbooks; no add-on Python
environment or collection is modified.
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env python3
"""Operational full-source install/update, not a Git patcher or release validator.
Python 3.11+, standard library only. Does not install dependencies, change groups,
start services, touch inventory/add-on data, or replace operator configuration.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import base64
from contextlib import contextmanager
from datetime import datetime, timezone
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import tomllib
import sys
import tempfile
class DeploymentError(Exception):
pass
def no_symlink(path: Path):
for part in (path, *path.parents):
if part.is_symlink():
raise DeploymentError('Refusing a symlink in a deployment path: ' + str(part))
def no_symlink_parents(path: Path):
for part in path.parents:
if part.is_symlink():
raise DeploymentError('Refusing a symlink in a deployment parent path: ' + str(part))
def canonical(path: Path) -> Path:
# Check before normalization so an intermediate symlink cannot disappear.
no_symlink(path.absolute())
return Path(os.path.abspath(path))
@contextmanager
def deployment_lock(target: Path):
lock = target / '.aim-core-deploy.lock'
fd = os.open(lock, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT | os.O_CLOEXEC | getattr(os, 'O_NOFOLLOW', 0), 0o600)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise DeploymentError('Deployment lock is not a regular file.')
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def allowed(relative: Path) -> bool:
parts = relative.parts
if relative.as_posix() in {'requirements.yml', 'requirements-controller.txt', 'deploy/deploy.py', 'deploy/README.md'}:
return True
if len(parts) >= 3 and parts[:3] == ('scripts', 'src', 'aim'):
return relative.suffix == '.py' or relative.as_posix() == 'scripts/src/aim/integrations/ansible.cfg'
if len(parts) >= 3 and parts[:2] == ('scripts', 'docs'):
return relative.suffix in ('.md', '.json')
if len(parts) == 2 and parts[0] == 'scripts':
return parts[1] in {'pyproject.toml', 'aim.yml', 'AIM-WinRM-OneTime.ps1', 'aimctl.py'}
if len(parts) == 3 and parts[:2] == ('playbooks', 'filter_plugins'):
return relative.suffix == '.py'
if len(parts) >= 2 and parts[0] == 'playbooks':
return relative.suffix in ('.yml', '.yaml', '.md')
if len(parts) >= 3 and parts[0] == 'roles':
if relative.name == 'README.md':
return True
return len(parts) >= 4 and parts[2] in ('tasks', 'defaults', 'handlers', 'meta', 'vars', 'templates') and relative.suffix in ('.yml', '.yaml', '.j2')
return False
def source_files(source: Path) -> dict[str, Path]:
result = {}
for parent, dirs, files in os.walk(source, followlinks=False):
dirs[:] = sorted(d for d in dirs if d != '__pycache__')
for d in dirs:
no_symlink(Path(parent) / d)
for name in sorted(files):
path = Path(parent) / name
relative = path.relative_to(source)
if path.suffix == '.pyc':
continue
if path.is_symlink() or not path.is_file() or not allowed(relative):
raise DeploymentError('Unexpected source entry; refusing deployment: ' + str(relative))
result[relative.as_posix()] = path
required = {'scripts/src/aim/__init__.py', 'scripts/pyproject.toml', 'scripts/docs/AGENTS.md', 'scripts/docs/ADDON_AGENTS.md', 'playbooks/aim_catalog.yml'}
if not required.issubset(result):
raise DeploymentError('Not a complete AIM replacement source tree.')
return result
def digest(path: Path | bytes) -> str:
if isinstance(path, bytes):
return hashlib.sha256(path).hexdigest()
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
LAUNCHER_MARKER = '# AIM core managed launcher v1'
def _launcher_digest(path: Path) -> str | None:
if path.is_symlink():
return digest(('symlink:' + os.readlink(path)).encode('utf-8'))
if path.exists():
return digest(path)
return None
def _read_launcher_text(path: Path) -> str:
candidate = path.resolve(strict=True) if path.is_symlink() else path
if not candidate.is_file() or candidate.stat().st_size > 65536:
raise DeploymentError('A non-launcher occupies ' + str(path))
try:
return candidate.read_text(encoding='utf-8')
except UnicodeDecodeError:
raise DeploymentError('Refusing to replace an unrecognized launcher: ' + str(path)) from None
def _restore_symlink(destination: Path, target: str):
no_symlink_parents(destination)
temporary = destination.parent / ('.aim-link-' + next(tempfile._get_candidate_names()))
try:
os.symlink(target, temporary)
os.replace(temporary, destination)
dfd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
finally:
temporary.unlink(missing_ok=True)
def _launcher_path(item, target: Path, launcher_dir: Path | None = None):
relative = Path(item['path'])
if item.get('kind') == 'launcher':
if launcher_dir is None or relative.parts not in (('@launchers', 'aim'), ('@launchers', 'aimctl')):
raise DeploymentError('Invalid launcher recovery path.')
directory = canonical(launcher_dir)
if not directory.is_absolute() or directory == Path('/'):
raise DeploymentError('Invalid launcher directory.')
destination = directory / relative.name
else:
if relative.is_absolute() or '..' in relative.parts or relative.parts[:1] == ('@launchers',):
raise DeploymentError('Invalid core source path.')
destination = target / relative
if item.get('kind') == 'launcher':
no_symlink_parents(destination)
else:
no_symlink(destination)
return destination
def _command(args, *, timeout=20):
env = os.environ.copy()
for name in ('PYTHONPATH', 'PYTHONHOME', 'PYTHONSTARTUP', 'PYTHONINSPECT'):
env.pop(name, None)
env['PYTHONDONTWRITEBYTECODE'] = '1'
try:
result = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=timeout, env=env, cwd='/')
except (OSError, subprocess.TimeoutExpired):
raise DeploymentError('AIM interpreter/launcher check could not complete; no dependency installation is attempted.') from None
if result.returncode:
raise DeploymentError('AIM interpreter/launcher check failed. Supply the existing AIM environment with --aim-python; ensure its Python 3.11+, ruamel.yaml and rich dependencies and operator configuration are usable.')
return result.stdout
@dataclass(frozen=True)
class EntryPoints:
python: Path
directory: Path
target: Path
def contents(self):
result = {}
for name, module in (('aim', 'aim.__main__'), ('aimctl', 'aim.ctl')):
metadata = json.dumps({'target': str(self.target), 'python': str(self.python), 'command': name}, sort_keys=True)
content = (f'#!{self.python}\n{LAUNCHER_MARKER}\n# {metadata}\n'
'import sys\n'
'sys.dont_write_bytecode = True\n'
f'sys.path.insert(0, {str(self.target / "scripts/src")!r})\n'
f'from {module} import main\n'
'if __name__ == "__main__":\n raise SystemExit(main())\n')
result['@launchers/' + name] = content.encode('utf-8')
return result
def verify_python(self, source):
# An explicit interpreter is an administrator-selected executable, not
# user-controlled input to an elevated web wrapper. Preserve venv symlinks.
expected = tomllib.loads((source / 'scripts/pyproject.toml').read_text())['project']['version']
code = ('import sys,json; assert sys.version_info >= (3,11); '
f'sys.path.insert(0, {str(source / "scripts/src")!r}); '
'import ruamel.yaml,rich,aim; from aim.ui.app import App; '
'from aim.services.v1 import AimService; from aim.ctl import main; '
'print(json.dumps({"version":aim.__version__}))')
value = json.loads(_command([str(self.python), '-I', '-B', '-c', code]))
if value.get('version') != expected:
raise DeploymentError('Extracted source/package versions disagree.')
return expected
def verify_installed(self, version):
got = _command([str(self.directory / 'aim'), '--version']).strip()
value = json.loads(_command([str(self.directory / 'aimctl'), '--config',
str(self.target / 'scripts/aim.yml'), 'capabilities']))
if got != 'AIM ' + version or not value.get('ok') or value.get('result', {}).get('core_version') != version:
raise DeploymentError('Installed AIM/aimctl launchers do not report the deployed core version.')
print('PASS installed aim --version and aimctl capabilities (' + version + ').')
def entry_points(target: Path, python: Path | None, directory: Path | None) -> EntryPoints:
existing = shutil.which('aim')
if target != Path('/etc/ansible') and directory is None:
raise DeploymentError('A nondefault --target requires an explicit --bin-dir to avoid replacing another installation\'s commands.')
if python is None:
if not existing:
raise DeploymentError('Cannot discover the AIM interpreter. Supply --aim-python /absolute/path/to/the/existing/AIM/bin/python.')
with Path(existing).open(encoding='utf-8') as stream:
first = stream.readline().strip()
if not first.startswith('#!/') or len(first[2:].split()) != 1 or Path(first[2:]).name not in ('python', 'python3', 'python3.11', 'python3.12', 'python3.13', 'python3.14'):
raise DeploymentError('The existing aim launcher has no unambiguous Python shebang. Supply --aim-python explicitly; shell/env wrappers are not guessed.')
python = Path(first[2:])
if not python.is_absolute() or not python.is_file() or not os.access(python, os.X_OK) or any(c.isspace() for c in str(python)) or len(str(python)) > 120:
raise DeploymentError('--aim-python must be an executable absolute, space-free Python path (maximum 120 characters). Venv symlinks are supported.')
python = Path(os.path.abspath(python)) # do NOT resolve a venv symlink to the system Python
directory = directory if directory is not None else (Path(existing).parent if existing else Path('/usr/local/bin'))
if not directory.is_absolute():
raise DeploymentError('--bin-dir must be absolute.')
directory = canonical(directory)
if directory == Path('/') or directory == target or directory.is_relative_to(target / 'scripts/src'):
raise DeploymentError('Use a dedicated command directory, not the root or core source namespace.')
if directory.exists() and not directory.is_dir():
raise DeploymentError('The command directory is not a directory.')
for name, module in (('aim', 'aim.__main__'), ('aimctl', 'aim.ctl')):
dest = directory / name
no_symlink_parents(dest)
if dest.exists() or dest.is_symlink():
if dest.is_symlink():
try:
resolved = dest.resolve(strict=True)
except (OSError, RuntimeError):
raise DeploymentError('Refusing a broken launcher symlink: ' + str(dest)) from None
if not resolved.is_file():
raise DeploymentError('Launcher symlink does not resolve to a regular file: ' + str(dest))
text = _read_launcher_text(dest)
if LAUNCHER_MARKER in text:
try:
info = json.loads(text.splitlines()[2][2:])
except (ValueError, IndexError):
raise DeploymentError('Invalid AIM launcher metadata: ' + str(dest)) from None
if info.get('target') != str(target):
raise DeploymentError('AIM launcher belongs to another installation; choose its own --bin-dir.')
elif f'from {module} import main' not in text:
raise DeploymentError('Refusing to replace an unrecognized launcher. Choose a reviewed --bin-dir: ' + str(dest))
return EntryPoints(python, directory, target)
def plan(source, target, mode, *, entrypoints=None):
source, target = canonical(source), canonical(target)
no_symlink(source)
no_symlink(target)
if target == Path('/') or source == target or source.is_relative_to(target) or target.is_relative_to(source):
raise DeploymentError('Use separate extracted-source and installation directories; never / as target.')
if mode == 'update' and not (target / 'scripts/src/aim/__init__.py').is_file():
raise DeploymentError('Existing AIM source was not found; use install for a fresh tree.')
files = source_files(source)
operations = []
for relative, src in sorted(files.items()):
dest = target / relative
no_symlink(dest)
if dest.exists() and not dest.is_file():
raise DeploymentError('A non-file occupies a core destination: ' + relative)
if relative == 'scripts/aim.yml' and dest.exists():
continue # operator-owned, always preserved, even on first install
if dest.exists() and digest(src) == digest(dest):
continue
operations.append({'path': relative, 'action': 'replace' if dest.exists() else 'create',
'old_sha256': digest(dest) if dest.exists() else None,
'new_sha256': digest(src)})
# Only the Python core namespace is an authoritative replaceable directory.
# Other unlisted playbooks/roles/files remain operator-owned additions.
core = target / 'scripts/src/aim'
if core.exists():
for parent, dirs, names in os.walk(core, followlinks=False):
for d in dirs:
no_symlink(Path(parent) / d)
for name in names:
existing = Path(parent) / name
no_symlink(existing)
relative = existing.relative_to(target).as_posix()
if relative not in files:
if not existing.is_file():
raise DeploymentError('Unsupported core namespace entry: ' + relative)
operations.append({'path': relative, 'action': 'remove', 'old_sha256': digest(existing), 'new_sha256': None})
# Explicitly retired Core document names only; unknown operator documents stay.
# Removal is previewed and recorded in the same protected rollback journal.
retired_docs = (
# Root/scripts-root AIM-owned docs moved into scripts/docs.
# Component-local docs (deploy/README.md, role READMEs, playbook docs) stay beside their code.
'AGENTS.md', 'ADDON_AGENTS.md', 'scripts/CHANGELOG.md',
'scripts/docs/RC19_HANDOFF.md', 'scripts/docs/SANITY_3.2.0.md',
'scripts/docs/SANITY_3.2.1rc2.md', 'scripts/docs/VERIFICATION.md',
'scripts/docs/LOCAL_VALIDATION.md', 'scripts/docs/CONTROLLER_ACCEPTANCE.md',
)
for relative in retired_docs:
existing = target / relative
no_symlink(existing)
if existing.exists() and relative not in files:
if not existing.is_file():
raise DeploymentError('Non-file occupies a retired document: ' + relative)
operations.append({'path': relative, 'action': 'remove',
'old_sha256': digest(existing), 'new_sha256': None})
if entrypoints is not None:
if entrypoints.target != target or entrypoints.directory == source or entrypoints.directory.is_relative_to(source):
raise DeploymentError('Launcher target/directory conflicts with the extracted source.')
for relative, content in entrypoints.contents().items():
dest = _launcher_path({'path': relative, 'kind': 'launcher'}, target, entrypoints.directory)
files[relative] = content
current = _launcher_digest(dest)
if not dest.is_symlink() and dest.exists() and current == digest(content) and os.access(dest, os.X_OK):
continue
operations.append({'path': relative, 'kind': 'launcher', 'action': 'replace' if (dest.exists() or dest.is_symlink()) else 'create',
'old_sha256': current, 'new_sha256': digest(content)})
return files, operations
def atomic_file(source: Path | bytes, destination: Path, *, metadata=None, executable=False, allow_replace_symlink=False):
destination.parent.mkdir(parents=True, exist_ok=True)
if allow_replace_symlink:
no_symlink_parents(destination)
else:
no_symlink(destination)
fd, filename = tempfile.mkstemp(prefix='.aim-install-', dir=destination.parent)
staged = Path(filename)
try:
with os.fdopen(fd, 'wb') as stream:
if isinstance(source, bytes):
stream.write(source)
else:
with source.open('rb') as incoming:
shutil.copyfileobj(incoming, stream)
stream.flush()
os.fsync(stream.fileno())
if metadata is None and destination.exists() and not destination.is_symlink():
old = destination.stat()
os.chown(staged, old.st_uid, old.st_gid)
# Preserve ACLs/attributes, not the old file timestamp.
for key in os.listxattr(destination):
os.setxattr(staged, key, os.getxattr(destination, key))
staged.chmod(stat.S_IMODE(old.st_mode) | (0o111 if executable else 0))
elif metadata is not None:
os.chown(staged, metadata['uid'], metadata['gid'])
for key, value in metadata.get('xattrs', {}).items():
os.setxattr(staged, key, base64.b64decode(value, validate=True))
staged.chmod(metadata['mode'])
else:
staged.chmod(0o755 if executable else 0o644)
os.replace(staged, destination)
dfd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
finally:
staged.unlink(missing_ok=True)
def apply(source, target, mode, backup_root, *, entrypoints=None):
source, target, backup_root = canonical(source), canonical(target), canonical(backup_root)
files, operations = plan(source, target, mode, entrypoints=entrypoints)
version = entrypoints.verify_python(source) if entrypoints else None
launcher_dir = entrypoints.directory if entrypoints else None
if not operations:
if entrypoints:
entrypoints.verify_installed(version)
print('AIM source and selected entry points are already current; operator configuration was preserved.')
return None
no_symlink(backup_root)
if backup_root == target or backup_root.is_relative_to(target) or backup_root == source or backup_root.is_relative_to(source):
raise DeploymentError('Backups must be outside the installation and extracted source trees.')
backup_root.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=backup_root))
backup.chmod(0o700)
record = {'target': str(target), 'state': 'preparing', 'entries': [], 'created_directories': [],
'format': 2, 'launcher_dir': str(launcher_dir) if launcher_dir else None}
for operation in operations:
dest = _launcher_path(operation, target, launcher_dir)
item = dict(operation)
if dest.is_symlink():
item['old_kind'] = 'symlink'
item['link_target'] = os.readlink(dest)
elif dest.exists():
st = dest.stat()
item['old_kind'] = 'file'
item['metadata'] = dict(uid=st.st_uid, gid=st.st_gid, mode=stat.S_IMODE(st.st_mode),
xattrs={key: base64.b64encode(os.getxattr(dest, key)).decode('ascii') for key in os.listxattr(dest)})
recovery = backup / 'files' / operation['path']
recovery.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(dest, recovery)
recovery.chmod(0o600)
record['entries'].append(item)
def save_record():
fd, temporary = tempfile.mkstemp(prefix='.recovery-', dir=backup)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
json.dump(record, stream, indent=2)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, backup / 'recovery.json')
dfd = os.open(backup, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
finally:
Path(temporary).unlink(missing_ok=True)
save_record()
try:
record['state'] = 'applying'
save_record()
for item in record['entries']:
dest = _launcher_path(item, target, launcher_dir)
current = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
if current != item['old_sha256']:
raise DeploymentError('Source changed during installation; stop writers and retry.')
item['started'] = True
save_record() # persist intent before changing any installed file
if item['action'] == 'remove':
dest.unlink()
else:
if digest(files[item['path']]) != item['new_sha256']:
raise DeploymentError('Extracted release source changed during installation.')
missing_dirs = []
parent = dest.parent
while not parent.exists():
missing_dirs.append(str(parent))
parent = parent.parent
record['created_directories'].extend(missing_dirs)
atomic_file(files[item['path']], dest, executable=item.get('kind') == 'launcher',
allow_replace_symlink=item.get('kind') == 'launcher')
item['applied'] = True
save_record()
if entrypoints:
entrypoints.verify_installed(version)
record['state'] = 'completed'
save_record()
print('AIM core source installed. Recovery directory: ' + str(backup))
print('Preserved existing scripts/aim.yml, inventories, Vaults, keys, add-ons, virtual environments and unlisted customer files.')
print('No dependencies, OS identities, permissions policy or services were provisioned.')
if entrypoints:
print('AIM and aimctl launchers: ' + str(entrypoints.directory))
print('Ensure this directory is in the operator PATH; use hash -r in existing shells.')
return backup
except BaseException:
# Ordinary failures can restore the source files already touched. A power
# loss/kill -9 is not an atomic whole-tree transaction: retain recovery data.
for item in reversed(record['entries']):
if not item.get('started') or item['path'] == 'scripts/aim.yml':
continue
dest = _launcher_path(item, target, launcher_dir)
now = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
if now == item['old_sha256'] and not (item.get('kind') == 'launcher' and dest.exists() and
stat.S_IMODE(dest.stat().st_mode) != item.get('metadata', {}).get('mode')):
continue
if now != item['new_sha256']:
raise DeploymentError('A concurrently modified file prevented rollback; use the protected recovery directory.')
if item['old_sha256'] is None:
dest.unlink(missing_ok=True)
elif item.get('old_kind') == 'symlink':
_restore_symlink(dest, item['link_target'])
else:
atomic_file(backup / 'files' / item['path'], dest, metadata=item['metadata'])
record['state'] = 'rolled_back_after_error'
save_record()
raise
def rollback(backup: Path, target: Path, *, execute: bool):
backup, target = canonical(backup), canonical(target)
path = backup / 'recovery.json'
if path.is_symlink() or not path.is_file():
raise DeploymentError('Recovery metadata is missing or unsafe.')
record = json.loads(path.read_text(encoding='utf-8'))
if record.get('target') != str(target) or record.get('state') not in ('completed', 'applying'):
raise DeploymentError('Recovery target/state does not match this installation.')
actions = []
launcher_dir = Path(record['launcher_dir']) if record.get('launcher_dir') else None
for item in record['entries']:
if not (item.get('started') or item.get('applied')):
continue
relative = Path(item['path'])
if relative.is_absolute() or '..' in relative.parts or relative.as_posix() == 'scripts/aim.yml':
# Initial install may have created aim.yml: never delete an operator's
# subsequent configuration through rollback. Always preserve this file.
if relative.as_posix() == 'scripts/aim.yml':
continue
raise DeploymentError('Unsafe recovery path.')
if item.get('kind') != 'launcher' and not allowed(relative) and relative.parts[:3] != ('scripts', 'src', 'aim'):
raise DeploymentError('Recovery may only address the core source namespace.')
dest = _launcher_path(item, target, launcher_dir)
now = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
if now == item['old_sha256'] and not (item.get('kind') == 'launcher' and dest.exists() and
stat.S_IMODE(dest.stat().st_mode) != item.get('metadata', {}).get('mode')):
continue # interruption before publication, or an already restored entry
if now != item['new_sha256']:
raise DeploymentError('Installed core source changed since deployment; refusing to overwrite it during rollback: ' + str(relative))
if item['old_sha256'] is not None and item.get('old_kind') != 'symlink':
recovered = backup / 'files' / relative
no_symlink(recovered)
if digest(recovered) != item['old_sha256']:
raise DeploymentError('Recovery file checksum mismatch.')
actions.append(item)
print('Rollback source files: ' + str(len(actions)))
if not execute:
print('Dry run only. Use --apply --quiesced to restore these source files.')
return
for item in reversed(actions):
dest = _launcher_path(item, target, launcher_dir)
if item['old_sha256'] is None:
dest.unlink(missing_ok=True)
elif item.get('old_kind') == 'symlink':
_restore_symlink(dest, item['link_target'])
else:
atomic_file(backup / 'files' / item['path'], dest, metadata=item['metadata'])
print('Core source and recorded launchers restored; no remote Ansible work was reversed. Use hash -r and verify aim/aimctl for the restored release.')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('operation', choices=('install', 'update', 'rollback'))
parser.add_argument('--target', type=Path, default=Path('/etc/ansible'))
parser.add_argument('--backup-dir', type=Path, default=Path('/var/backups/aim-core'))
parser.add_argument('--from-backup', type=Path)
parser.add_argument('--aim-python', type=Path, help='Existing AIM interpreter; inferred only from an unambiguous installed aim Python shebang')
parser.add_argument('--bin-dir', type=Path, help='Install aim and aimctl here; defaults to the existing aim command directory or /usr/local/bin')
group = parser.add_mutually_exclusive_group()
group.add_argument('--apply', action='store_true', help='Apply the planned core-source replacement')
group.add_argument('--dry-run', action='store_true', help='Preview only (the default)')
parser.add_argument('--quiesced', action='store_true', help='Confirm CLI/add-on jobs and other source writers have been stopped')
args = parser.parse_args(argv)
try:
if not args.target.is_absolute():
raise DeploymentError('An absolute non-root installation directory is required.')
args.target = canonical(args.target)
if args.target == Path('/'):
raise DeploymentError('An absolute non-root installation directory is required.')
if args.apply and not args.quiesced:
raise DeploymentError('Stop active CLI/add-on jobs and retry with --apply --quiesced.')
if args.operation == 'rollback':
if not args.from_backup:
raise DeploymentError('rollback requires --from-backup.')
if args.apply:
with deployment_lock(args.target):
rollback(args.from_backup, args.target, execute=True)
else:
rollback(args.from_backup, args.target, execute=False)
return 0
source = Path(__file__).absolute().parents[1]
entrypoints = entry_points(args.target, args.aim_python, args.bin_dir)
entrypoints.verify_python(source)
_, operations = plan(source, args.target, args.operation, entrypoints=entrypoints)
print('Target: ' + str(args.target))
print('AIM interpreter: ' + str(entrypoints.python))
print('Command directory: ' + str(entrypoints.directory))
for operation in operations:
print(operation['action'].upper() + ' ' + operation['path'])
print('Planned file operations: ' + str(len(operations)))
print('KEEP existing scripts/aim.yml and all unlisted runtime/add-on/customer files.')
if not args.apply:
print('Dry run only. Apply from this extracted archive with --apply --quiesced.')
return 0
args.target.mkdir(parents=True, exist_ok=True)
with deployment_lock(args.target):
apply(source, args.target, args.operation, args.backup_dir.absolute(), entrypoints=entrypoints)
return 0
except (DeploymentError, OSError, ValueError, KeyError, TypeError) as exc:
print('AIM deployment stopped: ' + str(exc), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
+90
View File
@@ -0,0 +1,90 @@
# AIM Changelog
## 2.1.x
### 2.1.2
- Removed the experimental ASCII shield.
- Retained bitformer orange `#ff7a00` accent.
- Added compact `bitformer · AIM · Ansible Inventory Manager` header.
- Preserved `p / n` pagination convention.
- No intended Ansible behavior changes.
### 2.1.1
- Changed the primary UI accent to bitformer orange.
- Introduced temporary ASCII branding experiment.
- Standardized pagination on `p / n`.
### 2.1.0
- Major operator-console UI/UX refactor.
- Split presentation into focused UI modules.
- Added customer overview/dashboard.
- Unified selectors, review screens, result presentation, pagination
and filtering.
- Kept interactive commands live where password/editor interaction is
required.
## 2.0.x
- Added domain WinRM GPO rollout.
- Added malformed-YAML handling and multiple GPO/AD/GPP reliability
hotfixes.
- Corrected Windows local-account credential handling to be
host-specific.
- Corrected new-customer domain UPN defaults.
- Final 2.0.7 GPP Scheduled Task XML fix removed the invalid inner
`LogonType` element and added immediate registration
execution/retries.
## 1.9.x
- Added semantic Git-style template/Vault validation.
- Added non-destructive template consolidation.
- Consolidated playbook categories and generic multi-select behavior.
## 1.8.x
- Added structured Vault template creation.
- Added Windows credential models.
- Added SSH-agent/private-key-passphrase integration.
- Added direct multi-select workflows.
## 1.7.x
- Added customer domain/network defaults.
- Added batch Windows member-server domain access.
## 1.6.x
- Added/expanded automated Sophos configuration.
- Improved lock cleanup and Sophos menu organization.
## 1.5.x
- Added Sophos defaults, host variables and customer-specific playbook
support.
## 1.4.x
- Split Windows domain-account and member-server access workflows.
## 1.3.x
- Added local/domain Windows account flows and safer credential
transport.
## 1.2.x
- Added Windows WinRM service-account bootstrap and localized
Administrators handling.
## 1.1.x
- Added curated playbooks, routine local-only inventory validation,
empty default host-vars files and Enter=`0` navigation behavior.
## 1.0.0
- Initial consolidated Python AIM base.
+67
View File
@@ -0,0 +1,67 @@
# AIM Development Guide
## Project root
``` text
/etc/ansible/scripts/
├── pyproject.toml
└── src/
└── aim/
```
The environment is installed editable:
``` bash
/etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
```
## Architecture
Backend areas include inventory loading/writing/validation, customer
management, backup/session recovery, locking, permissions, playbooks,
SSH, Vault, Sophos and WinRM.
The 2.1 UI is split into focused modules under `src/aim/ui/`, including
shared components, selection, execution, customers, hosts, access,
playbooks, administration and target selection.
## UI conventions
- Orange `#ff7a00` is the AIM/bitformer accent.
- Green = success.
- Yellow = warning.
- Red = failure.
- No ASCII logo.
- Header identity: `bitformer · AIM · Ansible Inventory Manager`.
- Pagination: `p / n`.
- Numbered navigation: Enter/`0` = Back or Cancel.
- Multi-select: number toggles; Enter reviews; `0` cancels.
Opening menus should not unexpectedly run Ansible, contact hosts,
decrypt Vaults or prompt for passwords.
## Inventory invariants
`hosts.yml` is authoritative. Preserve arbitrary valid YAML/custom
keys/comments where possible.
Writes should be validated and atomic, with stale-write/concurrency
protection and a session recovery backup.
Do not change unrelated Ansible connection/authentication parameters as
part of feature work.
## Packaging
Production packages should contain runtime source and metadata, without
caches, `.pyc`, test artifacts, legacy Bash implementations or developer
notes unless explicitly requested.
A release archive should expose `pyproject.toml` and `src/` at its root
rather than adding an extra wrapper directory.
## Versioning
Use a patch release for contained fixes and a feature/minor release for
larger functional changes. Update package metadata and the changelog
together.
+122
View File
@@ -0,0 +1,122 @@
# AIM Inventory Model
## Source of truth
The authoritative inventory is:
``` text
/etc/ansible/inventories/<customer>/hosts.yml
```
AIM does not use `.hosts.tsv` as a secondary database and does not
reverse-sync TSV data into YAML.
AIM uses round-trip YAML handling so valid manually maintained
structures/comments can be preserved where possible.
## Platform groups
Default top-level platform groups:
``` text
linux
windows
sophosxgs
pfsense
```
Platform remains top-level because it determines connection semantics
such as SSH, WinRM or HTTPAPI.
Hosts may have multiple memberships and optional one-level functional
subgroups.
## Linux
Linux group variables normally include the SSH connection and service
account. The customer SSH key directory is:
``` text
group_vars/linux/.ssh/
```
not:
``` text
group_vars/linux/files/.ssh/
```
`ansible_ssh_pass` may remain configured as a legacy
remote-login-password fallback. A private-key passphrase is a separate
secret.
## Windows
Domain-joined Windows hosts normally inherit the group-level service
identity/password.
A local-account host can override credentials in:
``` text
host_vars/<fqdn>/main.yml
```
Shared local example:
``` yaml
ansible_user: svc_bf-ansible
ansible_password: "{{ vault_windows_local_ansible_password }}"
```
Host-specific example:
``` yaml
ansible_user: svc_bf-ansible
ansible_password: "{{ vault_ansible_password_server01_example_lan }}"
```
## Host vars
Every newly managed host has:
``` text
host_vars/<fqdn>/main.yml
```
Existing host-vars content is not blindly overwritten.
## Customer defaults
AIM customer defaults live in:
``` text
/etc/ansible/inventories/<customer>/.aim.yml
```
Example:
``` yaml
domain_suffix: bfmiglabor.lan
network_address: 10.20.30.0
netmask: 255.255.255.0
ad_dns_domain: intra.company.de
ad_netbios_domain: COMPANY
```
The AD DNS domain is used for service-account UPNs. NetBIOS remains
metadata/legacy naming information.
## Safe writes
Inventory mutations use the conceptual sequence:
``` text
candidate temp file
→ local YAML validation
→ compare
→ session backup
→ atomic replace
```
AIM also protects against stale/concurrent writes and uses an inventory
lock.
+104
View File
@@ -0,0 +1,104 @@
# AIM Operations Guide
## Navigation
AIM is organized around a customer context:
``` text
Customer
├── Hosts Management
├── Access Management
├── Vault Management
├── Group Variables
├── Host Variables
├── Playbooks
└── Administration
```
For numbered navigation menus, Enter or `0` means Back/Cancel; at the
main menu it means Exit. Single selectors use Enter/`0` to cancel.
Multi-select uses numbers to toggle, Enter to review, and `0` to cancel.
Paginated views use `p / n` for Previous / Next.
## Customer overview
Opening a customer should provide local information without unexpectedly
contacting hosts, running Ansible or decrypting Vaults. The dashboard
includes inventory YAML state, Vault presence, host/platform counts and
available customer defaults.
## Hosts
`hosts.yml` is the source of truth.
Creating a host also ensures:
``` text
host_vars/<fqdn>/main.yml
```
For ordinary non-Sophos hosts this file may be empty. Existing host
variable files are not overwritten. Removing a host also removes its
corresponding host-vars directory.
Routine add/update/remove operations perform local YAML validation and
do not request the Vault password.
## Access Management
Linux access manages SSH keys/service-user access.
Windows access includes temporary WinRM testing, local account creation,
domain account creation/repair, member-server domain access, domain
WinRM GPO rollout and configured-service-user testing.
See `WINDOWS.md` for the Windows model.
## Vault Management
Vault operations include information, create, edit and delete.
A new Vault is populated as plaintext with mode `0600`, YAML-validated,
then encrypted using:
``` bash
ansible-vault encrypt --vault-id <customer>@prompt vault.yml
```
A failed encryption must not leave populated plaintext secrets behind.
## Variables
Group and host variable views are generally operator-readable without
AIM rewriting arbitrary custom configuration. AIM only changes values in
workflows explicitly designed to do so.
Template consolidation is explicit and non-destructive: missing AIM
defaults/comments can be added, while existing non-empty values and
custom keys are retained.
## Playbooks
Curated categories include CheckMK, Debug, Maintenance and Sophos XGS.
Compatible host/group selection is used to build the Ansible `--limit`.
Interactive playbooks and operations that require password/Vault prompts
retain live terminal access.
## Administration
Administration includes inventory validation, template consolidation,
recovery and customer defaults.
Explicit inventory validation performs YAML parsing and
`ansible-inventory`. When a customer Vault exists, validation uses the
customer's Vault identity and may prompt for its password.
AIM maintains one pre-change inventory recovery backup per inventory per
AIM process/session:
``` text
hosts.aim-session.bak.yml
```
Restores are explicit and YAML-validated.
+69
View File
@@ -0,0 +1,69 @@
# AIM Recovery Guide
## Principle
Git protects source code, not AIM's ignored runtime secrets.
A `git reset --hard` or fresh checkout cannot restore ignored data such
as inventory Vaults, SSH keys or a Python virtual environment.
## Critical persistent data
Back up separately:
``` text
/etc/ansible/inventories/<customer>/group_vars/all/vault.yml
/etc/ansible/inventories/<customer>/group_vars/linux/.ssh/
```
Other customer inventory YAML should also be covered by the system
backup policy.
## Rebuildable data
Do not recover `.venv` from Git. Rebuild it:
``` bash
sudo rm -rf /etc/ansible/.venv
sudo python3 -m venv /etc/ansible/.venv
sudo /etc/ansible/.venv/bin/python -m pip install --upgrade pip setuptools wheel
sudo /etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
sudo ln -sfn /etc/ansible/.venv/bin/aim /usr/local/bin/aim
```
## Recover Vaults and SSH keys from a filesystem backup
When restoring from a backup tree, copy only files that do not already
exist in the active inventory. Never overwrite surviving current secrets
as part of a bulk recovery.
See `../install.md` for the current dry-run and restore commands.
## AIM session inventory backup
AIM can create:
``` text
hosts.aim-session.bak.yml
```
This is a pre-change recovery aid, not the primary backup strategy for
customer secrets.
Restore from it only through an explicit recovery decision after
validating the relevant inventory state.
## Post-recovery validation
Before deleting the backup source:
1. Confirm Vault files exist for expected customers.
2. Confirm SSH private/public key files and permissions.
3. Test Vault decryption/access for representative customers.
4. Test Linux SSH authentication.
5. Test Windows WinRM for representative domain/local credential
models.
6. Test any other customer-specific access that depends on recovered
secrets.
Keep the filesystem backup until these checks pass.
+58
View File
@@ -0,0 +1,58 @@
# AIM Sensitive Data and Security Notes
## Sensitive files
AIM deliberately keeps secrets outside Git.
Important locations include:
``` text
/etc/ansible/inventories/<customer>/group_vars/all/vault.yml
/etc/ansible/inventories/<customer>/group_vars/linux/.ssh/
```
Vault files may contain Windows and Linux authentication material.
`.ssh` directories may contain private keys that cannot be regenerated
without coordinating key rotation on managed systems.
## Backup requirement
Git is not a backup for ignored secrets.
System backup procedures must include customer Vaults and SSH key
material. Recovery should preserve existing current files and restore
only missing data unless an operator explicitly chooses otherwise.
## Vault handling
AIM encrypts newly created Vaults with `ansible-vault`. Plaintext
populated Vault data should not remain on disk after a failed encryption
attempt.
Semantic Vault comparison must not print secret values. It should
compare key existence/state rather than exposing plaintext.
## SSH key handling
Private-key passphrases and remote SSH passwords are separate concepts.
The Linux private key location is:
``` text
group_vars/linux/.ssh/svc_bf-ansible
```
Private keys should have restrictive filesystem permissions.
## Authorization
AIM authorization is based on membership in a configured group resolved
through NSS/SSSD/winbind/local group services. The configured group name
is authoritative; numeric GIDs may differ across hosts.
Operators should verify effective membership with:
``` bash
getent group '<required-group>'
id
```
+133
View File
@@ -0,0 +1,133 @@
# Windows, WinRM and Active Directory
## Service account
The standard service account is:
``` text
svc_bf-ansible
```
For an AD domain, the configured identity is normally the UPN:
``` text
svc_bf-ansible@<ad_dns_domain>
```
The normal domain-account Vault variable is:
``` yaml
vault_windows_ansible_password: "..."
```
The shared-local-account Vault variable is:
``` yaml
vault_windows_local_ansible_password: "..."
```
## Windows credential models
New Windows hosts can use:
1. Domain service account
2. Shared local service account
3. Host-specific local service account
Local credential choices are host-specific overrides and must not
rewrite `group_vars/windows/main.yml` for every Windows machine.
## Domain account creation
AIM can create/repair the domain service identity and add it to the
appropriate built-in Administrators context used by the current design.
It does not make the account a Domain Admin.
Administrative bootstrap credentials should only be requested where
genuinely required.
## Member-server access
AIM can grant the existing domain service identity local Administrators
membership on selected member servers. Domain Controllers are
rejected/skipped for this workflow.
## Domain WinRM GPO rollout
The rollout uses one prepared, already-manageable Domain Controller as
its administration point.
The managed objects are:
``` text
AD group: GG_bitformer_Ansible_Admins
GPO: bitformer - Ansible WinRM
Task: bitformer - Configure Ansible WinRM
```
The GPO configures the local Administrators membership, deploys the
WinRM setup payload/scheduled task, configures HTTPS WinRM and firewall
access, and verifies the resulting state.
### Multiple target OUs
A single GPO should be linked to multiple selected OUs rather than
creating a separate GPO for Servers, Clients, etc.
Example:
``` text
bitformer - Ansible WinRM
├── OU=Servers,DC=intra,DC=company,DC=de
└── OU=Clients,DC=intra,DC=company,DC=de
```
The OU selector should therefore support multi-selection.
GPO link management is **additive and idempotent**:
- Selected OU already linked: keep/repair as appropriate.
- Selected OU not linked: create the link.
- Unselected OU: do nothing.
Selecting only `Servers` on a later run must **not** imply that an
existing `Clients` link should be removed.
Link removal should be an explicit operation if/when AIM implements it.
### Child OUs
AIM links the GPO to the selected OU. It should not create redundant
links on every descendant OU merely to emulate inheritance. Normal Group
Policy inheritance handles descendants unless AD policy configuration
changes that behavior.
### Domain Controllers
The Domain Controllers OU must remain unavailable/rejected for the
normal member-machine WinRM rollout.
## WinRM payload
The payload ensures WinRM is running, configures/reuses a suitable
certificate or creates a self-signed Server Authentication certificate,
creates the HTTPS listener, allows TCP/5986 and verifies the final
state.
The scheduled task runs immediately after registration and can retry
periodically. After successful verification it disables itself.
## Troubleshooting
Useful client-side checks include:
``` powershell
gpupdate /force
gpresult /h C:\Temp\gpresult.html
Get-Service WinRM
winrm enumerate winrm/config/listener
Get-ScheduledTask -TaskName "bitformer - Configure Ansible WinRM"
```
Also inspect Group Policy operational logs and Task Scheduler events
when Group Policy Preferences reports a task import failure.
+286
View File
@@ -0,0 +1,286 @@
# AIM Installation Guide
This guide installs AIM with `/etc/ansible/scripts` as the project root.
## Layout
``` text
/etc/ansible/scripts/
├── pyproject.toml
└── src/
└── aim/
/etc/ansible/.venv/
/usr/local/bin/aim -> /etc/ansible/.venv/bin/aim
```
The virtual environment is intentionally not stored in Git.
## 1. Prerequisites
On Debian/Ubuntu:
``` bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip
```
## 2. Rebuild the virtual environment
``` bash
sudo rm -rf /etc/ansible/.venv
sudo python3 -m venv /etc/ansible/.venv
sudo /etc/ansible/.venv/bin/python -m pip install --upgrade pip setuptools wheel
```
## 3. Install AIM
``` bash
sudo /etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
```
Verify:
``` bash
ls -l /etc/ansible/.venv/bin/aim
/etc/ansible/.venv/bin/python -m pip check
```
## 4. Restore the system-wide command
``` bash
sudo ln -sfn /etc/ansible/.venv/bin/aim /usr/local/bin/aim
```
Verify:
``` bash
ls -l /usr/local/bin/aim
aim --version
```
## 5. Authorization group / using another GID
AIM authorization is group-based rather than root-based. The default
required group is:
``` text
srv_debsansible01_admins@bitformer.lan
```
AIM resolves the configured **group name** through the operating
system's NSS layer (for example local groups, SSSD or winbind). The
operator must have that group active as a primary or supplementary
group.
Do not hard-code a numeric GID into AIM merely because a particular
server uses a different GID. Numeric GIDs can differ between systems;
configure the appropriate group name and let NSS resolve its GID.
Inspect the default group and its resolved GID:
``` bash
getent group 'srv_debsansible01_admins@bitformer.lan'
```
Check the current user's active groups/GIDs:
``` bash
id
id -G
id -Gn
```
If another authorization group should be used, change AIM's **Required
Group** through:
``` text
Global Config
└── Required Group
```
For example, if the local/AD-backed group is:
``` text
ansible_operators
```
verify it first:
``` bash
getent group ansible_operators
```
Then configure `ansible_operators` as AIM's Required Group. AIM will use
the GID returned by NSS for that group.
After adding a user to a group, the login session may need to be renewed
before the supplementary group becomes active. Verify with `id` before
troubleshooting AIM authorization.
### Same group name, different GID
This is supported. For example, one server may resolve:
``` text
ansible_operators:x:1200:...
```
and another may resolve:
``` text
ansible_operators:x:48001:...
```
AIM should be configured with `ansible_operators`, not `1200` or
`48001`.
### Different group name
Configure the alternative group name in AIM Global Config and confirm:
``` bash
getent group '<group-name>'
id
```
If `getent` cannot resolve the group, fix NSS/SSSD/winbind/local group
resolution first.
## 6. Git-ignored runtime data
Important ignored data includes:
``` gitignore
.vscode/*
.venv/*
.ansible/*
secure/*
secure/keys/*
vault.yml
.ssh
*.msi
*.deb
*.rpm
**/.hosts.tsv
**/hosts.yml.aim-session.bak
**/hosts.aim-session.bak.yml
*.bak
```
For AIM inventory recovery, the critical persistent data is `vault.yml`
and inventory `.ssh/` content. Do not restore `.venv`; rebuild it.
## 7. Recover missing Vaults and SSH keys
Example restored backup:
``` text
/etc/ansible/inventories_RESTORED_20260915_152127
```
Active inventories:
``` text
/etc/ansible/inventories
```
### Dry run
``` bash
BACKUP="/etc/ansible/inventories_RESTORED_20260915_152127"
TARGET="/etc/ansible/inventories"
echo "=== vault.yml ==="
sudo find "$BACKUP" -type f -name 'vault.yml' -print0 |
while IFS= read -r -d '' src; do
rel="${src#"$BACKUP"/}"
dst="$TARGET/$rel"
if [ -e "$dst" ]; then
echo "KEEP $dst"
else
echo "RESTORE $src -> $dst"
fi
done
echo
echo "=== .ssh files ==="
sudo find "$BACKUP" -path '*/.ssh/*' -type f -print0 |
while IFS= read -r -d '' src; do
rel="${src#"$BACKUP"/}"
dst="$TARGET/$rel"
if [ -e "$dst" ]; then
echo "KEEP $dst"
else
echo "RESTORE $src -> $dst"
fi
done
```
### Restore
``` bash
BACKUP="/etc/ansible/inventories_RESTORED_20260915_152127"
TARGET="/etc/ansible/inventories"
sudo find "$BACKUP" -type f -name 'vault.yml' -print0 |
while IFS= read -r -d '' src; do
rel="${src#"$BACKUP"/}"
dst="$TARGET/$rel"
if [ -e "$dst" ]; then
echo "KEEP $dst"
continue
fi
sudo mkdir -p "$(dirname "$dst")"
sudo cp -a "$src" "$dst"
echo "RESTORED $dst"
done
sudo find "$BACKUP" -path '*/.ssh/*' -type f -print0 |
while IFS= read -r -d '' src; do
rel="${src#"$BACKUP"/}"
dst="$TARGET/$rel"
if [ -e "$dst" ]; then
echo "KEEP $dst"
continue
fi
sudo mkdir -p "$(dirname "$dst")"
sudo cp -a "$src" "$dst"
echo "RESTORED $dst"
done
```
`cp -a` preserves ownership, permissions and timestamps. Existing active
files are never overwritten.
Verify:
``` bash
sudo find /etc/ansible/inventories -type f -name 'vault.yml' -print | sort
sudo find /etc/ansible/inventories -path '*/.ssh/*' -type f -print | sort
sudo find /etc/ansible/inventories -path '*/.ssh/*' -type f -exec ls -l {} \;
```
Keep the restored backup until Vault and SSH access have been tested.
## 8. Final verification
``` bash
aim --version
/etc/ansible/.venv/bin/python -m pip check
aim
```
## Updating AIM
AIM is installed editable from `/etc/ansible/scripts`.
If its entry point needs recreation:
``` bash
sudo /etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
sudo ln -sfn /etc/ansible/.venv/bin/aim /usr/local/bin/aim
```
Customer data under `/etc/ansible/inventories` is separate from the AIM
source installation.
View File
+5
View File
@@ -0,0 +1,5 @@
domain_suffix: desq-gaming.lan
network_address: 192.168.20.0
netmask: 255.255.255.0
ad_dns_domain: ''
ad_netbios_domain: ''
@@ -0,0 +1 @@
# group_vars/all/main.yml for desq_gaming
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDCE2LLoV
73yy1kzqzlRMRAAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIFdQAWVjarXDVETr
Lk4DepHUkaDNVJl41IZuCUCDKLM9AAAAoJwWeclw1w1YC5uWBbb1JaMH2q9fa1YDSvg4Gs
bNuZM8UEmh2pYkOBBPmL3mbTkcq2igF5IbJarhTzfebKCj9hMI3tXPyK9c6torPwA5uOiy
NuQ1jUcAuAJ+wN9jzKwYpE54GaOAJiGEVippJREkF1X49iGwtB51zWJ9qFXotJmHOO55ap
cjwWPtAwuqHIO9b2gfikiFlF4IJqKHFBz7m/Q=
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFdQAWVjarXDVETrLk4DepHUkaDNVJl41IZuCUCDKLM9 svc_bf-ansible@desq_gaming
@@ -0,0 +1,7 @@
# Linux / SSH variables
ansible_connection: ssh
ansible_user: svc_bf-ansible
ansible_private_key_file:
/etc/ansible/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible
ansible_ssh_pass: '{{ ansible_vault_linux_ssh_pass }}'
ansible_become_method: sudo
@@ -0,0 +1,2 @@
# pfSense-specific variables
{}
@@ -0,0 +1,7 @@
# SophosXGS-specific variables
#ansible_password: "{{ ansible_password }}" # Passwort wird aus --ask-pass übernommen
ansible_user: admin
ansible_connection: ansible.netcommon.httpapi
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 4444
ansible_network_os: sophos.sophos_firewall.sfos
@@ -0,0 +1,7 @@
# Windows / WinRM variables
ansible_connection: winrm
ansible_port: 5986
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
ansible_user: svc_bf-ansible
ansible_password: '{{ vault_windows_ansible_password }}'
@@ -0,0 +1,3 @@
# AIM-managed host-specific Windows local service account credentials.
ansible_user: svc_bf-ansible
ansible_password: '{{ vault_ansible_password_desktop_robert_desq_gaming_lan }}'
@@ -0,0 +1,99 @@
all:
children:
desq_gaming:
children:
linux:
children:
networking:
hosts:
dewenpm01.desq-gaming.lan:
ansible_host: 192.168.20.3
dewedns02.desq-gaming.lan:
ansible_host: 192.168.20.2
dewesrv-unifi02.desq-gaming.lan:
ansible_host: 192.168.99.5
backup:
hosts:
dewepbs01.desq-gaming.lan:
ansible_host: 192.168.99.32
proxmox:
hosts:
dewepbs01.desq-gaming.lan:
ansible_host: 192.168.99.32
dewepve01.desq-gaming.lan:
ansible_host: 192.168.99.31
hosting:
hosts:
dewepve01.desq-gaming.lan:
ansible_host: 192.168.99.31
management:
hosts:
dewesrv-ansible01.desq-gaming.lan:
ansible_host: 192.168.20.46
dewesrv-patch01.desq-gaming.lan:
ansible_host: 192.168.20.45
applications:
hosts:
dewesrv-budget02.desq-gaming.lan:
ansible_host: 192.168.20.44
dewesrv-cache02.desq-gaming.lan:
ansible_host: 192.168.20.24
dewesrv-cloud01.desq-gaming.lan:
ansible_host: 192.168.20.14
dewesrv-crafty02.desq-gaming.lan:
ansible_host: 192.168.20.19
dewesrv-db01.desq-gaming.lan:
ansible_host: 192.168.20.21
dewesrv-db02.desq-gaming.lan:
ansible_host: 192.168.20.23
dewesrv-docker02.desq-gaming.lan:
ansible_host: 192.168.20.30
dewesrv-git01.desq-gaming.lan:
ansible_host: 192.168.20.38
dewesrv-grafana01.desq-gaming.lan:
ansible_host: 192.168.20.22
dewesrv-ha01.desq-gaming.lan:
ansible_host: 192.168.30.10
dewesrv-homarr01.desq-gaming.lan:
ansible_host: 192.168.20.35
dewesrv-mail01.desq-gaming.lan:
ansible_host: 192.168.20.40
dewesrv-nodejs01.desq-gaming.lan:
ansible_host: 192.168.20.20
dewesrv-omv01.desq-gaming.lan:
ansible_host: 192.168.20.15
dewesrv-overseerr01.desq-gaming.lan:
ansible_host: 192.168.20.18
dewesrv-plex02.desq-gaming.lan:
ansible_host: 192.168.20.39
dewesrv-puppet01.desq-gaming.lan:
ansible_host: 192.168.20.25
dewesrv-recipe01.desq-gaming.lan:
ansible_host: 192.168.20.37
dewesrv-rust02.desq-gaming.lan:
ansible_host: 192.168.20.27
dewesrv-speed01.desq-gaming.lan:
ansible_host: 192.168.20.16
dewesrv-steam01.desq-gaming.lan:
ansible_host: 192.168.20.12
dewesrv-support01.desq-gaming.lan:
ansible_host: 192.168.20.28
dewesrv-tautulli01.desq-gaming.lan:
ansible_host: 192.168.20.17
dewesrv-tv01.desq-gaming.lan:
ansible_host: 192.168.20.43
dewesrv-uptime01.desq-gaming.lan:
ansible_host: 192.168.20.11
dewesrv-vault01.desq-gaming.lan:
ansible_host: 192.168.20.34
dewesrv-wallos01.desq-gaming.lan:
ansible_host: 192.168.20.29
dewesrv-wazuh01.desq-gaming.lan:
ansible_host: 192.168.20.13
dewesrv-wiki01.desq-gaming.lan:
ansible_host: 192.168.20.36
+105
View File
@@ -0,0 +1,105 @@
all:
children:
desq_gaming:
children:
linux:
children:
networking:
hosts:
dewenpm01.desq-gaming.lan:
ansible_host: 192.168.20.3
dewedns02.desq-gaming.lan:
ansible_host: 192.168.20.2
dewesrv-unifi02.desq-gaming.lan:
ansible_host: 192.168.99.5
backup:
hosts:
dewepbs01.desq-gaming.lan:
ansible_host: 192.168.99.32
proxmox:
hosts:
dewepbs01.desq-gaming.lan:
ansible_host: 192.168.99.32
dewepve01.desq-gaming.lan:
ansible_host: 192.168.99.31
hosting:
hosts:
dewepve01.desq-gaming.lan:
ansible_host: 192.168.99.31
management:
hosts:
dewesrv-ansible01.desq-gaming.lan:
ansible_host: 192.168.20.46
dewesrv-patch01.desq-gaming.lan:
ansible_host: 192.168.20.45
applications:
hosts:
dewesrv-budget02.desq-gaming.lan:
ansible_host: 192.168.20.44
dewesrv-cache02.desq-gaming.lan:
ansible_host: 192.168.20.24
dewesrv-cloud01.desq-gaming.lan:
ansible_host: 192.168.20.14
dewesrv-crafty02.desq-gaming.lan:
ansible_host: 192.168.20.19
dewesrv-db01.desq-gaming.lan:
ansible_host: 192.168.20.21
dewesrv-db02.desq-gaming.lan:
ansible_host: 192.168.20.23
dewesrv-docker02.desq-gaming.lan:
ansible_host: 192.168.20.30
dewesrv-git01.desq-gaming.lan:
ansible_host: 192.168.20.38
dewesrv-grafana01.desq-gaming.lan:
ansible_host: 192.168.20.22
dewesrv-ha01.desq-gaming.lan:
ansible_host: 192.168.30.10
dewesrv-homarr01.desq-gaming.lan:
ansible_host: 192.168.20.35
dewesrv-mail01.desq-gaming.lan:
ansible_host: 192.168.20.40
dewesrv-nodejs01.desq-gaming.lan:
ansible_host: 192.168.20.20
dewesrv-omv01.desq-gaming.lan:
ansible_host: 192.168.20.15
dewesrv-overseerr01.desq-gaming.lan:
ansible_host: 192.168.20.18
dewesrv-plex02.desq-gaming.lan:
ansible_host: 192.168.20.39
dewesrv-puppet01.desq-gaming.lan:
ansible_host: 192.168.20.25
dewesrv-recipe01.desq-gaming.lan:
ansible_host: 192.168.20.37
dewesrv-rust02.desq-gaming.lan:
ansible_host: 192.168.20.27
dewesrv-speed01.desq-gaming.lan:
ansible_host: 192.168.20.16
dewesrv-steam01.desq-gaming.lan:
ansible_host: 192.168.20.12
dewesrv-support01.desq-gaming.lan:
ansible_host: 192.168.20.28
dewesrv-tautulli01.desq-gaming.lan:
ansible_host: 192.168.20.17
dewesrv-tv01.desq-gaming.lan:
ansible_host: 192.168.20.43
dewesrv-uptime01.desq-gaming.lan:
ansible_host: 192.168.20.11
dewesrv-vault01.desq-gaming.lan:
ansible_host: 192.168.20.34
dewesrv-wallos01.desq-gaming.lan:
ansible_host: 192.168.20.29
dewesrv-wazuh01.desq-gaming.lan:
ansible_host: 192.168.20.13
dewesrv-wiki01.desq-gaming.lan:
ansible_host: 192.168.20.36
windows:
children:
client:
hosts:
DESKTOP-ROBERT.desq-gaming.lan:
ansible_host: 192.168.10.11
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
---
# PURPOSE: Preview / clean up Checkmk scripts
# DESCRIPTION: List obsolete managed script paths; remove them only with explicit deletion approval.
# TARGETS: linux, windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# checkmk_cleanup_enabled [bool]: false
# checkmk_unifi_mode [choice]: auto
# want_linux_check_certificate [bool]: false
# want_windows_citrix [bool]: false
# want_windows_surebackup [bool]: false
# want_windows_backup [bool]: false
# want_windows_nsp_mailqueue [bool]: false
# want_windows_certificate [bool]: false
# want_windows_veeam_cloud_connect [bool]: false
# want_windows_veeam_backup [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Cleanup only touches the documented managed filenames. Preview is the default; enabling deletion requires another confirmation.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/checkmk_cleanup_scripts.yml --limit <host> --vault-id <customer>@prompt
- name: Checkmk | Preview or clean up linux
hosts: linux
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
become: true
roles:
- role: system_detect_roles
- role: checkmk_script_plan
- role: checkmk_cleanup_scripts
- name: Checkmk | Preview or clean up windows
hosts: windows
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
roles:
- role: system_detect_roles
- role: checkmk_script_plan
- role: checkmk_cleanup_scripts
+136
View File
@@ -0,0 +1,136 @@
# PURPOSE: Install Checkmk agent
# DESCRIPTION: Install staged agent packages, deploy selected checks, render Windows settings and ensure the agent is running.
# TARGETS: linux, windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# checkmk_unifi_mode [choice]: auto
# checkmk_unifi_username [text]: bf-monitoring
# checkmk_unifi_password [secret_ref]: vault_checkmk_unifi_password
# checkmk_unifi_baseurl [url]: network: https://127.0.0.1:8443; os: https://127.0.0.1:11443
# checkmk_unifi_curl_options [text]: --insecure --tlsv1.2
# want_linux_check_certificate [bool]: false
# want_windows_citrix [bool]: false
# want_windows_surebackup [bool]: false
# want_windows_backup [bool]: false
# want_windows_nsp_mailqueue [bool]: false
# want_windows_certificate [bool]: false
# want_windows_veeam_cloud_connect [bool]: false
# want_windows_veeam_backup [bool]: false
# checkmk_unifi_status_provisioning [int]: 1
# checkmk_unifi_status_upgrading [int]: 1
# checkmk_unifi_status_upgradable [int]: 0
# checkmk_unifi_status_heartbeat_missed [int]: 1
# checkmk_unifi_status_noautobackup [int]: 0
# checkmk_windows_updates_timeout [int]: 3600
# checkmk_windows_updates_cache [int]: 43200
# checkmk_mk_inventory_timeout [int]: 120
# checkmk_plugins_default_timeout [int]: 120
# checkmk_plugins_default_cache [int]: 600
# checkmk_extra_plugin_patterns [sequence]: []
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Installs packages and AIM-managed script files. On Windows only the marked plugins section in check_mk.user.yml is replaced; all other user-config sections are preserved. UniFi deployment also removes the alternative UniFi local check; no other cleanup is performed.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/checkmk_install_agent.yml --limit <host> --vault-id <customer>@prompt
- name: Checkmk | Install linux
hosts: linux
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Load system detect roles
ansible.builtin.include_role:
name: system_detect_roles
- name: Load checkmk script plan
ansible.builtin.include_role:
name: checkmk_script_plan
- name: Checkmk | Preflight scripts and credentials
ansible.builtin.include_role:
name: checkmk_deploy_scripts
tasks_from: preflight
become: true
roles:
- role: checkmk_agent
- role: checkmk_deploy_scripts
- role: checkmk_configure_agent
- role: checkmk_manage_service
post_tasks:
- name: Checkmk | Observe installed agent
ansible.builtin.include_role:
name: checkmk_report
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: checkmk_agent_state_v1
data: '{{ _aim_checkmk_state }}'
- name: Checkmk | Install windows
hosts: windows
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Load system detect roles
ansible.builtin.include_role:
name: system_detect_roles
- name: Load checkmk script plan
ansible.builtin.include_role:
name: checkmk_script_plan
- name: Checkmk | Preflight scripts and credentials
ansible.builtin.include_role:
name: checkmk_deploy_scripts
tasks_from: preflight
roles:
- role: checkmk_agent
- role: checkmk_deploy_scripts
- role: checkmk_configure_agent
- role: checkmk_manage_service
post_tasks:
- name: Checkmk | Observe installed agent
ansible.builtin.include_role:
name: checkmk_report
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: checkmk_agent_state_v1
data: '{{ _aim_checkmk_state }}'
+115
View File
@@ -0,0 +1,115 @@
# PURPOSE: Read current Windows Checkmk user configuration
# DESCRIPTION: Display the current check_mk.user.yml from Windows without modifying the host.
# TARGETS: windows
# INPUTS (omitted values inherit inventory / playbook defaults):
# aim_debug [bool]: false
# checkmk_windows_user_cfg [text]: C:\ProgramData\checkmk\agent\check_mk.user.yml
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: none; this playbook is read-only.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/checkmk_read_windows_config.yml --limit <host> --vault-id <customer>@prompt
- name: Checkmk | Read Windows user configuration
hosts: windows
gather_facts: false
tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Validate Checkmk configuration path
ansible.builtin.assert:
that:
- (checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml')) is
string
- (checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml') | trim
| length) > 0
fail_msg: checkmk_windows_user_cfg must be a non-empty Windows path.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
configuration: "{{ checkmk_windows_user_cfg | default('C:\\\\ProgramData\\\\checkmk\\\\agent\\\\check_mk.user.yml')
}}"
mode: Read-only; no Checkmk configuration is modified.
when: aim_debug | default(false) | bool
- name: Windows | Inspect current Checkmk user configuration
ansible.windows.win_stat:
path: "{{ checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml') }}"
get_checksum: false
register: _checkmk_windows_user_config_stat
changed_when: false
- name: Windows | Require the approved Checkmk user filename
ansible.builtin.assert:
that:
- not (_checkmk_windows_user_config_stat.stat.exists | default(false)) or (_checkmk_windows_user_config_stat.stat.filename | lower) == 'check_mk.user.yml'
- not (_checkmk_windows_user_config_stat.stat.exists | default(false)) or (_checkmk_windows_user_config_stat.stat.isreg | default(false))
- (_checkmk_windows_user_config_stat.stat.size | default(0) | int) <= 524288
fail_msg: Only a regular check_mk.user.yml file up to 512 KiB may be read.
quiet: true
- name: Windows | Read current Checkmk user configuration
ansible.windows.slurp:
src: "{{ checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml') }}"
register: _checkmk_windows_user_config_slurp
when: _checkmk_windows_user_config_stat.stat.exists | default(false)
no_log: true
- name: Windows | Build Checkmk user configuration result
ansible.builtin.set_fact:
_checkmk_windows_user_config:
exists: '{{ _checkmk_windows_user_config_stat.stat.exists | default(false) | bool }}'
path: "{{ _checkmk_windows_user_config_stat.stat.path | default(checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml')) }}"
size_bytes: '{{ _checkmk_windows_user_config_stat.stat.size | default(0) | int }}'
last_write_time_utc: >-
{{ (_checkmk_windows_user_config_stat.stat.lastwritetime | aim_epoch_iso_utc)
if (_checkmk_windows_user_config_stat.stat.exists | default(false)) else none }}
content: >-
{{ (_checkmk_windows_user_config_slurp.content | b64decode)
if (_checkmk_windows_user_config_stat.stat.exists | default(false)) else '' }}
changed_when: false
no_log: true
- name: Windows | Report missing Checkmk user configuration
ansible.builtin.debug:
msg: |-
{{ inventory_hostname }}
Checkmk user configuration was not found.
Path: {{ _checkmk_windows_user_config.path }}
when: not (_checkmk_windows_user_config.exists | bool)
- name: Windows | Print current Checkmk user configuration
ansible.builtin.debug:
msg: |-
{{ inventory_hostname }}
Path: {{ _checkmk_windows_user_config.path }}
Size: {{ _checkmk_windows_user_config.size_bytes }} bytes
Last write (UTC): {{ _checkmk_windows_user_config.last_write_time_utc }}
----- BEGIN check_mk.user.yml -----
{{ _checkmk_windows_user_config.content }}
----- END check_mk.user.yml -----
when: _checkmk_windows_user_config.exists | bool
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: checkmk_user_config_v1
data: '{{ _checkmk_windows_user_config | aim_report_checkmk_config }}'
+135
View File
@@ -0,0 +1,135 @@
# PURPOSE: Update Checkmk scripts and configuration
# DESCRIPTION: Deploy selected checks and Windows configuration without installing agent packages.
# TARGETS: linux, windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# checkmk_unifi_mode [choice]: auto
# checkmk_unifi_username [text]: bf-monitoring
# checkmk_unifi_password [secret_ref]: vault_checkmk_unifi_password
# checkmk_unifi_baseurl [url]: network: https://127.0.0.1:8443; os: https://127.0.0.1:11443
# checkmk_unifi_curl_options [text]: --insecure --tlsv1.2
# want_linux_check_certificate [bool]: false
# want_windows_citrix [bool]: false
# want_windows_surebackup [bool]: false
# want_windows_backup [bool]: false
# want_windows_nsp_mailqueue [bool]: false
# want_windows_certificate [bool]: false
# want_windows_veeam_cloud_connect [bool]: false
# want_windows_veeam_backup [bool]: false
# checkmk_unifi_status_provisioning [int]: 1
# checkmk_unifi_status_upgrading [int]: 1
# checkmk_unifi_status_upgradable [int]: 0
# checkmk_unifi_status_heartbeat_missed [int]: 1
# checkmk_unifi_status_noautobackup [int]: 0
# checkmk_windows_updates_timeout [int]: 3600
# checkmk_windows_updates_cache [int]: 43200
# checkmk_mk_inventory_timeout [int]: 120
# checkmk_plugins_default_timeout [int]: 120
# checkmk_plugins_default_cache [int]: 600
# checkmk_extra_plugin_patterns [sequence]: []
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: AIM-managed script files are updated. On Windows only the marked plugins section in check_mk.user.yml is replaced; all other user-config sections are preserved. Only the opposite UniFi check is removed during a UniFi mode transition.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/checkmk_update_scripts_config.yml --limit <host> --vault-id <customer>@prompt
- name: Checkmk | Update linux
hosts: linux
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Load system detect roles
ansible.builtin.include_role:
name: system_detect_roles
- name: Load checkmk script plan
ansible.builtin.include_role:
name: checkmk_script_plan
- name: Checkmk | Preflight scripts and credentials
ansible.builtin.include_role:
name: checkmk_deploy_scripts
tasks_from: preflight
become: true
roles:
- role: checkmk_deploy_scripts
- role: checkmk_configure_agent
- role: checkmk_manage_service
post_tasks:
- &id001
name: Checkmk | Collect managed change summary
ansible.builtin.set_fact:
_aim_checkmk_changes: '{{ _aim_check_copies | default({}) | aim_report_checkmk_changes(_checkmk_selected_scripts,
_checkmk_plugins_update | default({}), _aim_unifi_write | default({}), _aim_opposite_remove | default({}),
_checkmk_unifi_effective, ansible_check_mode) }}'
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: checkmk_agent_config_v1
data: '{{ _aim_checkmk_changes }}'
- name: Checkmk | Update windows
hosts: windows
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Load system detect roles
ansible.builtin.include_role:
name: system_detect_roles
- name: Load checkmk script plan
ansible.builtin.include_role:
name: checkmk_script_plan
- name: Checkmk | Preflight scripts and credentials
ansible.builtin.include_role:
name: checkmk_deploy_scripts
tasks_from: preflight
roles:
- role: checkmk_deploy_scripts
- role: checkmk_configure_agent
- role: checkmk_manage_service
post_tasks:
- *id001
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: checkmk_agent_config_v1
data: '{{ _aim_checkmk_changes }}'
@@ -0,0 +1,96 @@
---
# PURPOSE: Apply customer Sophos configuration
# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
# TARGETS: sophosxgs
# REQUIRED NETWORK KEYS: derz_lan, derz_sslvpn, facility, guest, lan_old, management, office, server, voip
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/customers/bluuunit/sophos_apply_customer.yml --limit <host> --vault-id <customer>@prompt --ask-pass
- name: Sophos | Apply customer configuration | bluuunit
hosts: sophosxgs
gather_facts: false
any_errors_fatal: false
tasks:
- name: Apply customer firewall policy
ansible.builtin.import_role:
name: sophos_customer_bluuunit
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Sophos | Require customer host configuration
ansible.builtin.assert:
that:
- hostname is defined
- hostname is string
- hostname | length > 0
- network_objects is defined
- network_objects is mapping
- vlan_interfaces is defined
- vlan_interfaces is mapping
fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
these fields.
quiet: true
- name: Sophos | Require profile network keys
ansible.builtin.assert:
that:
- item in network_objects
fail_msg: A customer-required network_objects key is missing. See the loop item.
quiet: true
loop:
- derz_lan
- derz_sslvpn
- facility
- guest
- lan_old
- management
- office
- server
- voip
- name: Sophos | Validate network object shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.network is defined
- item.value.subnetmask is defined
fail_msg: Every network object requires name, network and subnetmask.
quiet: true
loop: '{{ network_objects | dict2items }}'
loop_control:
label: '{{ item.key }}'
- name: Sophos | Validate VLAN shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.ip_address is defined
- item.value.subnetmask is defined
- item.value.vlan_id is defined
- item.value.zone_name is defined
- item.value.zone_type is defined
- item.value.zone_description is defined
fail_msg: Each VLAN requires its full interface and zone mapping.
quiet: true
loop: '{{ vlan_interfaces | dict2items }}'
loop_control:
label: '{{ item.key }}'
@@ -0,0 +1,91 @@
---
# PURPOSE: Apply customer Sophos configuration
# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
# TARGETS: sophosxgs
# REQUIRED NETWORK KEYS: azuregwc_lan, lan_old, management, office
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/customers/formicon/sophos_apply_customer.yml --limit <host> --vault-id <customer>@prompt --ask-pass
- name: Sophos | Apply customer configuration | formicon
hosts: sophosxgs
gather_facts: false
any_errors_fatal: false
tasks:
- name: Apply customer firewall policy
ansible.builtin.import_role:
name: sophos_customer_formicon
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Sophos | Require customer host configuration
ansible.builtin.assert:
that:
- hostname is defined
- hostname is string
- hostname | length > 0
- network_objects is defined
- network_objects is mapping
- vlan_interfaces is defined
- vlan_interfaces is mapping
fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
these fields.
quiet: true
- name: Sophos | Require profile network keys
ansible.builtin.assert:
that:
- item in network_objects
fail_msg: A customer-required network_objects key is missing. See the loop item.
quiet: true
loop:
- azuregwc_lan
- lan_old
- management
- office
- name: Sophos | Validate network object shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.network is defined
- item.value.subnetmask is defined
fail_msg: Every network object requires name, network and subnetmask.
quiet: true
loop: '{{ network_objects | dict2items }}'
loop_control:
label: '{{ item.key }}'
- name: Sophos | Validate VLAN shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.ip_address is defined
- item.value.subnetmask is defined
- item.value.vlan_id is defined
- item.value.zone_name is defined
- item.value.zone_type is defined
- item.value.zone_description is defined
fail_msg: Each VLAN requires its full interface and zone mapping.
quiet: true
loop: '{{ vlan_interfaces | dict2items }}'
loop_control:
label: '{{ item.key }}'
@@ -0,0 +1,91 @@
---
# PURPOSE: Apply customer Sophos configuration
# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
# TARGETS: sophosxgs
# REQUIRED NETWORK KEYS: drucker, guest, office, wlan
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/customers/gebhardt_stahl/sophos_apply_customer.yml --limit <host> --vault-id <customer>@prompt --ask-pass
- name: Sophos | Apply customer configuration | gebhardt_stahl
hosts: sophosxgs
gather_facts: false
any_errors_fatal: false
tasks:
- name: Apply customer firewall policy
ansible.builtin.import_role:
name: sophos_customer_gebhardt_stahl
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Sophos | Require customer host configuration
ansible.builtin.assert:
that:
- hostname is defined
- hostname is string
- hostname | length > 0
- network_objects is defined
- network_objects is mapping
- vlan_interfaces is defined
- vlan_interfaces is mapping
fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
these fields.
quiet: true
- name: Sophos | Require profile network keys
ansible.builtin.assert:
that:
- item in network_objects
fail_msg: A customer-required network_objects key is missing. See the loop item.
quiet: true
loop:
- drucker
- guest
- office
- wlan
- name: Sophos | Validate network object shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.network is defined
- item.value.subnetmask is defined
fail_msg: Every network object requires name, network and subnetmask.
quiet: true
loop: '{{ network_objects | dict2items }}'
loop_control:
label: '{{ item.key }}'
- name: Sophos | Validate VLAN shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.ip_address is defined
- item.value.subnetmask is defined
- item.value.vlan_id is defined
- item.value.zone_name is defined
- item.value.zone_type is defined
- item.value.zone_description is defined
fail_msg: Each VLAN requires its full interface and zone mapping.
quiet: true
loop: '{{ vlan_interfaces | dict2items }}'
loop_control:
label: '{{ item.key }}'
@@ -0,0 +1,92 @@
---
# PURPOSE: Apply customer Sophos configuration
# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
# TARGETS: sophosxgs
# REQUIRED NETWORK KEYS: facility, guest, management, office, voip
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/customers/hungeling_und_toechter/sophos_apply_customer.yml --limit <host> --vault-id <customer>@prompt --ask-pass
- name: Sophos | Apply customer configuration | hungeling_und_toechter
hosts: sophosxgs
gather_facts: false
any_errors_fatal: false
tasks:
- name: Apply customer firewall policy
ansible.builtin.import_role:
name: sophos_customer_hungeling_und_toechter
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Sophos | Require customer host configuration
ansible.builtin.assert:
that:
- hostname is defined
- hostname is string
- hostname | length > 0
- network_objects is defined
- network_objects is mapping
- vlan_interfaces is defined
- vlan_interfaces is mapping
fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
these fields.
quiet: true
- name: Sophos | Require profile network keys
ansible.builtin.assert:
that:
- item in network_objects
fail_msg: A customer-required network_objects key is missing. See the loop item.
quiet: true
loop:
- facility
- guest
- management
- office
- voip
- name: Sophos | Validate network object shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.network is defined
- item.value.subnetmask is defined
fail_msg: Every network object requires name, network and subnetmask.
quiet: true
loop: '{{ network_objects | dict2items }}'
loop_control:
label: '{{ item.key }}'
- name: Sophos | Validate VLAN shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.ip_address is defined
- item.value.subnetmask is defined
- item.value.vlan_id is defined
- item.value.zone_name is defined
- item.value.zone_type is defined
- item.value.zone_description is defined
fail_msg: Each VLAN requires its full interface and zone mapping.
quiet: true
loop: '{{ vlan_interfaces | dict2items }}'
loop_control:
label: '{{ item.key }}'
@@ -0,0 +1,93 @@
---
# PURPOSE: Apply customer Sophos configuration
# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
# TARGETS: sophosxgs
# REQUIRED NETWORK KEYS: facility, guest, management, office, server, voip
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/customers/koenig_holding_gmbh/sophos_apply_customer.yml --limit <host> --vault-id <customer>@prompt --ask-pass
- name: Sophos | Apply customer configuration | koenig_holding_gmbh
hosts: sophosxgs
gather_facts: false
any_errors_fatal: false
tasks:
- name: Apply customer firewall policy
ansible.builtin.import_role:
name: sophos_customer_koenig_holding_gmbh
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Sophos | Require customer host configuration
ansible.builtin.assert:
that:
- hostname is defined
- hostname is string
- hostname | length > 0
- network_objects is defined
- network_objects is mapping
- vlan_interfaces is defined
- vlan_interfaces is mapping
fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
these fields.
quiet: true
- name: Sophos | Require profile network keys
ansible.builtin.assert:
that:
- item in network_objects
fail_msg: A customer-required network_objects key is missing. See the loop item.
quiet: true
loop:
- facility
- guest
- management
- office
- server
- voip
- name: Sophos | Validate network object shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.network is defined
- item.value.subnetmask is defined
fail_msg: Every network object requires name, network and subnetmask.
quiet: true
loop: '{{ network_objects | dict2items }}'
loop_control:
label: '{{ item.key }}'
- name: Sophos | Validate VLAN shape
ansible.builtin.assert:
that:
- item.value is mapping
- item.value.name is defined
- item.value.ip_address is defined
- item.value.subnetmask is defined
- item.value.vlan_id is defined
- item.value.zone_name is defined
- item.value.zone_type is defined
- item.value.zone_description is defined
fail_msg: Each VLAN requires its full interface and zone mapping.
quiet: true
loop: '{{ vlan_interfaces | dict2items }}'
loop_control:
label: '{{ item.key }}'
+62
View File
@@ -0,0 +1,62 @@
# PURPOSE: Detect host roles
# DESCRIPTION: Report detected AD, DHCP, Hyper-V, Veeam and UniFi capabilities without changing inventory memberships.
# TARGETS: linux, windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/debug_detect_host_roles.yml --limit <host> --vault-id <customer>@prompt
- name: Debug | Detected host roles
hosts: linux:windows
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
roles:
- role: system_detect_roles
tasks:
- name: Detection summary
ansible.builtin.debug:
msg:
is_dc: '{{ is_dc | default(false) }}'
is_dhcp_server: '{{ is_dhcp_server | default(false) }}'
is_hyperv_host: '{{ is_hyperv_host | default(false) }}'
has_veeam_vbr: '{{ has_veeam_vbr | default(false) }}'
has_veeam_vbo: '{{ has_veeam_vbo | default(false) }}'
has_veeam_em: '{{ has_veeam_em | default(false) }}'
is_unifi_controller: '{{ is_unifi_controller | default(false) }}'
is_unifi_os_server: '{{ is_unifi_os_server | default(false) }}'
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: host_capabilities_v1
data:
is_dc: '{{ is_dc | default(false) | bool }}'
is_dhcp_server: '{{ is_dhcp_server | default(false) | bool }}'
is_hyperv_host: '{{ is_hyperv_host | default(false) | bool }}'
has_veeam_vbr: '{{ has_veeam_vbr | default(false) | bool }}'
has_veeam_vbo: '{{ has_veeam_vbo | default(false) | bool }}'
has_veeam_em: '{{ has_veeam_em | default(false) | bool }}'
is_unifi_controller: '{{ is_unifi_controller | default(false) | bool }}'
is_unifi_os_server: '{{ is_unifi_os_server | default(false) | bool }}'
+185
View File
@@ -0,0 +1,185 @@
# PURPOSE: Show disk usage
# DESCRIPTION: Report attached Windows volumes and common operational Linux mounts.
# TARGETS: windows, linux
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# NOTES:
# Windows reports attached storage volumes from community.windows.win_disk_facts; mapped/network drives are excluded.
# Linux excludes pseudo/system mounts and reports common operational paths plus network/storage filesystems.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/debug_show_disk_usage.yml --limit <host> --vault-id <customer>@prompt
- name: Debug | Windows disk usage
hosts: windows
gather_facts: false
tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Windows | Gather attached disk and volume facts
community.windows.win_disk_facts:
filter:
- partitions
- volumes
changed_when: false
- name: Windows | Normalize attached volume usage
ansible.builtin.set_fact:
_windows_disk_report: "{{ ansible_facts.disks | default([]) | aim_report_disks('windows') }}"
- name: Windows | Print disk usage
ansible.builtin.debug:
msg: |-
{{ inventory_hostname }}
{% for d in _windows_disk_report.filesystems | default([]) %}
{% if d.status == 'available' %}
{{ '%-18s' | format(d.name) }} {{ (d.used_bytes / 1073741824) | round(2) }} GB / {{ (d.total_bytes / 1073741824) | round(2) }} GB | {{ d.used_percent | round(1) }}% used | {{ (d.available_bytes / 1073741824) | round(2) }} GB free | {{ d.filesystem_type | default('unknown', true) }} | {{ d.mount }}
{% else %}
{{ '%-18s' | format(d.name) }} unavailable | {{ d.mount }}
{% endif %}
{% endfor %}
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: filesystem_usage_v1
data: "{{ _windows_disk_report }}"
- name: Debug | Linux disk usage
hosts: linux
gather_facts: false
become: false
vars:
_disk_common_mounts:
- /
- /boot
- /boot/efi
- /home
- /var
- /var/log
- /tmp
- /opt
- /srv
_disk_network_storage_fstypes:
- nfs
- nfs4
- cifs
- smb3
- ceph
- glusterfs
- fuse.sshfs
- fuse.glusterfs
_disk_excluded_fstypes:
- proc
- sysfs
- devtmpfs
- tmpfs
- cgroup
- cgroup2
- overlay
- squashfs
- nsfs
- tracefs
- debugfs
- securityfs
- pstore
- configfs
- hugetlbfs
- mqueue
- rpc_pipefs
- autofs
- fusectl
- binfmt_misc
tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
['true', 'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: Linux | Collect mount facts
ansible.builtin.setup:
filter:
- ansible_mounts
gather_subset:
- '!all'
- '!min'
changed_when: false
- name: Linux | Reset selected mount report
ansible.builtin.set_fact:
_linux_disk_mounts: []
- name: Linux | Select common operational mounts
ansible.builtin.set_fact:
_linux_disk_mounts: '{{ (_linux_disk_mounts | default([])) + [item] }}'
loop: '{{ ansible_facts.mounts | default(ansible_mounts | default([])) }}'
loop_control:
label: "{{ item.mount | default('?') }}"
when:
- item.fstype | default('') not in _disk_excluded_fstypes
- >-
(item.mount | default('')) in _disk_common_mounts
or (item.mount | default('')).startswith('/mnt/')
or (item.mount | default('')).startswith('/media/')
or (item.fstype | default('')) in _disk_network_storage_fstypes
- name: Linux | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
selected_mounts: "{{ (_linux_disk_mounts | default([])) | map(attribute='mount') | list }}"
diagnostics: Enabled; pseudo/system mounts are excluded.
when: aim_debug | default(false) | bool
- name: Linux | Print disk usage
ansible.builtin.debug:
msg: |-
{{ inventory_hostname }}
{% for m in (_linux_disk_mounts | default([]) | sort(attribute='mount')) %}
{% set total = m.size_total | default(0) | float %}
{% set free = m.size_available | default(0) | float %}
{% set used = total - free %}
{% set pct = ((used / total) * 100) if total > 0 else 0 %}
{{ '%-18s' | format(m.mount) }} {{ (used / 1073741824) | round(2) }} GB / {{ (total / 1073741824) | round(2) }} GB | {{ pct | round(1) }}% used | {{ (free / 1073741824) | round(2) }} GB free | {{ m.fstype }}
{% endfor %}
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: filesystem_usage_v1
data: "{{ _linux_disk_mounts | aim_report_disks('linux') }}"
+61
View File
@@ -0,0 +1,61 @@
---
# PURPOSE: Test Ansible connection
# DESCRIPTION: Check Ansible manageability using ping or win_ping; this is not an ICMP ping.
# TARGETS: linux, windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/debug_test_connection.yml --limit <host> --vault-id <customer>@prompt
- name: Debug | Windows manageability
hosts: windows
gather_facts: false
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
tasks:
- name: Windows | Test WinRM
ansible.windows.win_ping: {}
- name: Debug | Linux manageability
hosts: linux
gather_facts: false
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
tasks:
- name: Linux | Test SSH and Python
ansible.builtin.ping: {}
+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}
@@ -0,0 +1,37 @@
---
# PURPOSE: Export Windows event logs
# DESCRIPTION: Export selected event channels to EVTX files on the target; existing event logs are not cleared.
# TARGETS: windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# event_age_days [int]: 45
# export_folder [text]: C:\Logs
# event_log_channels [list]: Application, Security, System, Setup
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/maintenance_export_event_logs.yml --limit <host> --vault-id <customer>@prompt
- name: Maintenance | Export Windows event logs
hosts: windows
gather_facts: false
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
roles:
- role: maintenance_export_event_logs
+70
View File
@@ -0,0 +1,70 @@
---
# PURPOSE: Patch operating systems
# DESCRIPTION: Apply updates on Windows, Debian and RedHat-family systems; optionally reboot when required.
# TARGETS: linux, windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# os_patching_reboot [bool]: true
# os_patching_windows_categories [list]: SecurityUpdates, CriticalUpdates, UpdateRollups, DefinitionUpdates, Updates
# os_patching_serial [serial]: 100%
# os_patching_reboot_timeout [int]: 600
# os_patching_reboot_delay_minutes [int]: 0
# os_patching_reboot_message [text]: AIM maintenance: operating system patching requires a reboot.
# os_patching_rescan_after_reboot [bool]: false (Windows only; continue with a newly discovered patch wave after reboot)
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Updates production operating systems. Optional AIM-initiated reboots notify logged-in users and honor the configured delay.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/maintenance_patch_os.yml --limit <host> --vault-id <customer>@prompt
- name: Maintenance | Patch Linux
hosts: linux
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
become: true
serial: '{{ os_patching_serial | default(''100%'') }}'
roles:
- role: maintenance_patch_os
- name: Maintenance | Patch Windows
hosts: windows
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
serial: '{{ os_patching_serial | default(''100%'') }}'
roles:
- role: maintenance_patch_os
+68
View File
@@ -0,0 +1,68 @@
---
# PURPOSE: Reboot hosts
# DESCRIPTION: Reboot selected hosts in batches and wait for management connectivity.
# TARGETS: linux, windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# maintenance_reboot_serial [serial]: 10
# maintenance_reboot_timeout [int]: 1800
# maintenance_reboot_message [text]: Reboot initiated by debsansible01.bfmiglabor.lan (Ansible)
# maintenance_reboot_pre_delay [int]: 0
# maintenance_reboot_post_delay [int]: 15
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Every selected host will be rebooted. No reboot occurs before final confirmation.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/maintenance_reboot_hosts.yml --limit <host> --vault-id <customer>@prompt
- name: Maintenance | Reboot Linux
hosts: linux
gather_facts: true
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
become: true
serial: '{{ maintenance_reboot_serial | default(10) }}'
roles:
- role: maintenance_reboot_hosts
- name: Maintenance | Reboot Windows
hosts: windows
gather_facts: false
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
serial: '{{ maintenance_reboot_serial | default(10) }}'
roles:
- role: maintenance_reboot_hosts
@@ -0,0 +1,37 @@
---
# PURPOSE: Start stopped automatic services
# DESCRIPTION: Start eligible stopped services, apply optional include/exclude lists and report partial failures.
# TARGETS: windows
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# maintenance_service_include [list]: []
# maintenance_service_exclude [list]: []
# maintenance_service_fail_on_error [bool]: true
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/maintenance_start_stopped_services.yml --limit <host> --vault-id <customer>@prompt
- name: Maintenance | Start automatic services
hosts: windows
gather_facts: false
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
roles:
- role: maintenance_start_stopped_services
+63
View File
@@ -0,0 +1,63 @@
---
# PURPOSE: Apply bitformer pfSense baseline
# DESCRIPTION: Apply the supplied pfSense baseline without changing its firewall/VPN policy.
# TARGETS: pfsense
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Contains the original any-source WAN management rule for ports 22/80/443. The original CA, VPN endpoint and client certificate reference are unchanged; verify them before execution. Requires separately approved pfsensible.core installation.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/pfsense_apply_baseline.yml --limit <host> --vault-id <customer>@prompt
- name: Install pfSense sudo package
hosts: pfsense
tasks:
- name: Apply supplied firewall policy
ansible.builtin.import_role:
name: pfsense_install_prerequisites
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
- name: Initial pfSense bitformer config
hosts: pfsense
become: true
tasks:
- name: Apply supplied firewall policy
ansible.builtin.import_role:
name: pfsense_apply_baseline
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
@@ -0,0 +1,77 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
mode:
type: string
maxLength: 128
enum:
- apply
- check
changed:
type: boolean
managed_sections:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
changes:
type: array
items:
type: object
properties:
component:
type: string
maxLength: 128
enum:
- section
- check
- configuration
name:
type: string
maxLength: 1024
action:
type: string
maxLength: 128
enum:
- updated
- removed
required:
- component
- name
- action
additionalProperties: false
maxItems: 20000
deployed_checks:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
changed_checks:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
removed_checks:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
unknown_files_policy:
type: string
maxLength: 128
enum:
- untouched_not_enumerated
required:
- mode
- changed
- managed_sections
- changes
- deployed_checks
- changed_checks
- removed_checks
- unknown_files_policy
additionalProperties: false
@@ -0,0 +1,74 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
mode:
type: string
maxLength: 128
enum:
- apply
- check
installed:
type:
- boolean
- 'null'
version:
type:
- string
- 'null'
maxLength: 1024
version_source:
type: string
maxLength: 128
enum:
- registry
- package_database
- unavailable
services:
type: array
items:
type: object
properties:
name:
type: string
maxLength: 1024
state:
type: string
maxLength: 1024
required:
- name
- state
additionalProperties: false
maxItems: 20000
package_changed: &id001
type: boolean
configuration_updated: *id001
checks_deployed:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
changed_checks:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
removed_checks:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
required:
- mode
- installed
- version
- version_source
- services
- package_changed
- configuration_updated
- checks_deployed
- changed_checks
- removed_checks
additionalProperties: false
@@ -0,0 +1,40 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
path:
type: string
maxLength: 1024
exists:
type: boolean
size_bytes:
type: integer
minimum: 0
last_write_time_utc:
type:
- string
- 'null'
maxLength: 1024
sections:
type: object
properties: {}
additionalProperties: true
redacted_paths:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
comment_preservation:
type: string
maxLength: 128
enum:
- not_in_structured_output
required:
- path
- exists
- size_bytes
- last_write_time_utc
- sections
- redacted_paths
- comment_preservation
additionalProperties: false
+30
View File
@@ -0,0 +1,30 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
mode:
type: string
maxLength: 128
enum:
- apply
- check
days:
type: integer
minimum: 0
files:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
channels:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
required:
- mode
- days
- files
- channels
additionalProperties: false
+66
View File
@@ -0,0 +1,66 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
platform:
type: string
maxLength: 128
enum:
- windows
- linux
filesystems:
type: array
items:
type: object
properties:
name:
type: string
maxLength: 1024
mount:
type: string
maxLength: 1024
filesystem_type:
type:
- string
- 'null'
maxLength: 1024
used_bytes:
type:
- integer
- 'null'
minimum: 0
total_bytes:
type:
- integer
- 'null'
minimum: 0
available_bytes:
type:
- integer
- 'null'
minimum: 0
used_percent:
type:
- number
- 'null'
minimum: 0
status:
type: string
maxLength: 128
enum:
- available
- unavailable
required:
- name
- mount
- filesystem_type
- used_bytes
- total_bytes
- available_bytes
- used_percent
- status
additionalProperties: false
maxItems: 20000
required:
- platform
- filesystems
additionalProperties: false
@@ -0,0 +1,22 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
is_dc: &id001
type: boolean
is_dhcp_server: *id001
is_hyperv_host: *id001
has_veeam_vbr: *id001
has_veeam_vbo: *id001
has_veeam_em: *id001
is_unifi_controller: *id001
is_unifi_os_server: *id001
required:
- is_dc
- is_dhcp_server
- is_hyperv_host
- has_veeam_vbr
- has_veeam_vbo
- has_veeam_em
- is_unifi_controller
- is_unifi_os_server
additionalProperties: false
@@ -0,0 +1,43 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
mode:
type: string
maxLength: 128
enum:
- preview
- apply
- check
directory:
type: string
maxLength: 1024
candidates:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
removed:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
unifi_configuration:
type: string
maxLength: 128
enum:
- retained
- candidate
- removed
- unchanged
complete:
type: boolean
required:
- mode
- directory
- candidates
- removed
- unifi_configuration
- complete
additionalProperties: false
+190
View File
@@ -0,0 +1,190 @@
# AIM public operation-data schema. Closed fields; no raw package-manager/module output.
type: object
properties:
platform:
type: string
maxLength: 128
enum:
- windows
- debian
- redhat
mode:
type: string
maxLength: 128
enum:
- apply
- check
evidence:
type: string
maxLength: 128
enum:
- package_snapshots
- windows_update_result
- preflight_reboot_state
complete:
type: boolean
updates:
type: array
items:
type: object
properties:
name:
type: string
maxLength: 1024
identifier:
type: [string, 'null']
maxLength: 1024
architecture:
type: [string, 'null']
maxLength: 1024
old_versions:
type: array
items:
type: string
maxLength: 1024
maxItems: 100
new_versions:
type: array
items:
type: string
maxLength: 1024
maxItems: 100
action:
type: string
maxLength: 128
enum: [updated, installed, removed]
kb:
type: array
items:
type: string
maxLength: 1024
maxItems: 100
required: [name, identifier, architecture, old_versions, new_versions, action, kb]
additionalProperties: false
maxItems: 20000
updated_count:
type: integer
minimum: 0
installed_count:
type: integer
minimum: 0
removed_count:
type: integer
minimum: 0
pending:
type: array
items:
type: object
properties:
name:
type: string
maxLength: 1024
identifier:
type: [string, 'null']
maxLength: 1024
kb:
type: array
items:
type: string
maxLength: 1024
maxItems: 100
required: [name, identifier, kb]
additionalProperties: false
maxItems: 20000
failed_updates:
type: array
items:
type: object
properties:
name:
type: string
maxLength: 1024
identifier:
type: [string, 'null']
maxLength: 1024
native_code:
type: [integer, 'null']
minimum: 0
native_code_hex:
type: [string, 'null']
maxLength: 32
reason:
type: string
maxLength: 128
enum: [update_failed, operation_in_progress, install_not_allowed, not_applicable, exclusive_install_conflict, self_update_in_progress, no_connection, timeout]
message:
type: string
maxLength: 512
required: [name, identifier, native_code, native_code_hex, reason, message]
additionalProperties: false
maxItems: 20000
reboot_required:
type: [boolean, 'null']
description: Final observed/predicted pending reboot state after this run.
reboot_required_before:
type: boolean
description: A pending reboot was detected before patching began.
reboot_reasons_before:
type: array
description: Windows only. Native reboot sources reported by ansible.windows.win_reboot_info before patching.
items:
type: object
properties:
source:
type: string
maxLength: 128
description:
type: string
maxLength: 512
required: [source, description]
additionalProperties: false
maxItems: 32
reboot_required_after:
type: [boolean, 'null']
description: Final pending reboot state; false after an AIM-performed successful reboot.
reboot_performed:
type: boolean
reboot_deferred:
type: boolean
description: A reboot remains required because automatic reboot was disabled.
reboot_delay_minutes:
type: integer
minimum: 0
maximum: 1440
blocked_reason:
type: [string, 'null']
maxLength: 128
enum: [preexisting_reboot_required, update_failed, operation_in_progress, install_not_allowed, not_applicable, exclusive_install_conflict, self_update_in_progress, no_connection, timeout, cycle_limit_reached, null]
rescan_after_reboot:
type: boolean
description: Windows only. True permits a newly discovered patch wave after an AIM-performed reboot.
patch_cycles:
type: integer
minimum: 0
maximum: 12
description: Windows discovery/install cycles entered by this run.
continuation_required:
type: boolean
description: Another operator-approved patch run is recommended or required to continue patching.
remaining_updates_known:
type: boolean
description: True only when the report contains an authoritative post-wave discovery in pending.
required:
- platform
- mode
- evidence
- complete
- updates
- updated_count
- installed_count
- removed_count
- pending
- failed_updates
- reboot_required
- reboot_required_before
- reboot_required_after
- reboot_performed
- reboot_deferred
- reboot_delay_minutes
- blocked_reason
additionalProperties: false
@@ -0,0 +1,113 @@
# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
type: object
properties:
mode:
type: string
maxLength: 128
enum:
- apply
- check
initially_stopped:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
eligible:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
attempted:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
excluded:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
newly_running:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
still_stopped:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
unobserved:
type: array
items:
type: string
maxLength: 1024
maxItems: 20000
failed_to_start:
type: array
items:
type: object
properties:
name:
type: string
maxLength: 1024
reason:
type: string
maxLength: 128
enum:
- dependency_failed
- permission_denied
- service_disabled
- start_timeout
- service_not_found
- logon_failed
- start_failed
- not_running_after_start
- state_unavailable
message:
type: string
maxLength: 1024
native_code:
type:
- integer
- 'null'
minimum: 0
required:
- name
- reason
- message
- native_code
additionalProperties: false
maxItems: 20000
started_count:
type: integer
minimum: 0
failed_count:
type: integer
minimum: 0
before_count:
type: integer
minimum: 0
after_observed:
type: boolean
required:
- mode
- initially_stopped
- eligible
- attempted
- excluded
- newly_running
- still_stopped
- unobserved
- failed_to_start
- started_count
- failed_count
- before_count
- after_observed
additionalProperties: false
+63
View File
@@ -0,0 +1,63 @@
---
# PURPOSE: Apply bitformer Sophos baseline
# DESCRIPTION: Apply the supplied bitformer firewall baseline. Existing policy values and action order are preserved.
# TARGETS: sophosxgs
# INPUTS (omitted values inherit inventory / role defaults):
# aim_debug [bool]: false
# AUTH: existing inventory / Vault credentials; no embedded passwords.
# CHANGES: Changes firewall management access, objects and rules, including rule removal and a final drop rule. Policy values have NOT been redesigned.
# EXAMPLE: ansible-playbook -i inventories/<customer>/hosts.yml
# playbooks/sophos_apply_baseline.yml --limit <host> --vault-id <customer>@prompt --ask-pass
- name: Grundkonfiguration der Sophos-Firewall nach bitformer Standard
hosts: sophosxgs
gather_facts: false
any_errors_fatal: false
vars:
network_hosts:
- name: bf_spn_network
network: 10.242.176.0
subnetmask: 255.255.255.0
- name: rfc_1918_a
network: 10.0.0.0
subnetmask: 255.0.0.0
- name: rfc_1918_b
network: 172.16.0.0
subnetmask: 255.240.0.0
- name: rfc_1918_c
network: 192.168.0.0
subnetmask: 255.255.0.0
- name: rfc_5735
network: 169.254.0.0
subnetmask: 255.255.0.0
firewall_rules_to_remove:
- '[example] Traffic to Internal Zones'
- '[example] Traffic to WAN'
- '[example] Traffic to DMZ'
wireless_networks_to_remove:
- GuestAP
- Sophos
tasks:
- name: Apply supplied firewall policy
ansible.builtin.import_role:
name: sophos_apply_baseline
tasks_from: main
pre_tasks:
- name: AIM | Validate diagnostics option
ansible.builtin.assert:
that:
- (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
'false']
fail_msg: aim_debug must be a YAML/JSON boolean.
quiet: true
- name: AIM | Reject mixed platform membership
ansible.builtin.assert:
that:
- (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
quiet: true
- name: AIM | Execution context
ansible.builtin.debug:
msg:
host: '{{ inventory_hostname }}'
diagnostics: Enabled; secret values are never included by this task.
when: aim_debug | default(false) | bool
+2
View File
@@ -0,0 +1,2 @@
# AIM canonical Ansible runtime. Separate from the AIM/add-on environments.
ansible-core==2.19.11
+10
View File
@@ -0,0 +1,10 @@
# requirements.yml
collections:
- name: ansible.netcommon
- name: ansible.windows
version: ">=3.8.0,<4.0.0" # win_reboot_info baseline; compatible with ansible-core 2.19.11
- name: ansible.posix
- name: community.windows
- name: community.general
- name: sophos.sophos_firewall
- name: pfsensible.core
+23
View File
@@ -0,0 +1,23 @@
# checkmk_agent
Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
```yaml
---
checkmk_linux_tmp_path: /tmp
checkmk_windows_tmp_path: C:\Windows\Temp
checkmk_deb_filename: check-mk-agent.deb
checkmk_rpm_filename: check-mk-agent.rpm
checkmk_msi_filename: check_mk_agent.msi
checkmk_agent_files_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_AGENT_FILES_DIR'') | default(role_path
~ ''/files'', true) }}'
```
## Structured result integration
Current reporting behavior and field semantics are specified in
[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
The calling catalog playbook owns publication; helper roles do not implicitly export
arbitrary facts, module results or debug data. Existing defaults above retain their
precedence. See the current validation/sanity documents before using the new candidate.
+9
View File
@@ -0,0 +1,9 @@
---
checkmk_linux_tmp_path: /tmp
checkmk_windows_tmp_path: C:\Windows\Temp
checkmk_deb_filename: check-mk-agent.deb
checkmk_rpm_filename: check-mk-agent.rpm
checkmk_msi_filename: check_mk_agent.msi
checkmk_agent_files_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_AGENT_FILES_DIR'') | default(role_path
~ ''/files'', true) }}'
+3
View File
@@ -0,0 +1,3 @@
# Staged agent packages
AIM Maintenance populates `check_mk_agent.msi`, `check-mk-agent.deb` and `check-mk-agent.rpm` here by default. Binaries are not included or replaced by this source bundle. Existing package files must be retained.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
---
- name: Checkmk | Verify staged package on controller
ansible.builtin.stat:
path: '{{ checkmk_agent_files_dir }}/{{ checkmk_deb_filename }}'
delegate_to: localhost
become: false
register: _checkmk_package
- name: Checkmk | Require staged package
ansible.builtin.assert:
that:
- _checkmk_package.stat.isreg | default(false)
- _checkmk_package.stat.size | default(0) | int > 0
fail_msg: Required agent package is absent/empty. Use AIM Maintenance to download packages first.
quiet: true
- name: Debian | Copy Checkmk agent package from role files
ansible.builtin.copy:
src: '{{ checkmk_agent_files_dir }}/{{ checkmk_deb_filename }}'
dest: '{{ checkmk_linux_tmp_path }}/{{ checkmk_deb_filename }}'
mode: '0644'
- name: Debian | Install Checkmk agent from local .deb
ansible.builtin.apt:
deb: '{{ checkmk_linux_tmp_path }}/{{ checkmk_deb_filename }}'
state: present
register: _checkmk_package_install
@@ -0,0 +1,25 @@
---
- name: Checkmk | Verify staged package on controller
ansible.builtin.stat:
path: '{{ checkmk_agent_files_dir }}/{{ checkmk_rpm_filename }}'
delegate_to: localhost
become: false
register: _checkmk_package
- name: Checkmk | Require staged package
ansible.builtin.assert:
that:
- _checkmk_package.stat.isreg | default(false)
- _checkmk_package.stat.size | default(0) | int > 0
fail_msg: Required agent package is absent/empty. Use AIM Maintenance to download packages first.
quiet: true
- name: RedHat | Copy Checkmk agent package from role files
ansible.builtin.copy:
src: '{{ checkmk_agent_files_dir }}/{{ checkmk_rpm_filename }}'
dest: '{{ checkmk_linux_tmp_path }}/{{ checkmk_rpm_filename }}'
mode: '0644'
- name: RedHat | Install Checkmk agent from local .rpm
ansible.builtin.dnf:
name: '{{ checkmk_linux_tmp_path }}/{{ checkmk_rpm_filename }}'
state: present
register: _checkmk_package_install
+17
View File
@@ -0,0 +1,17 @@
---
- name: Checkmk | Supported installer
ansible.builtin.assert:
that:
- ansible_facts.os_family in ['Debian','RedHat','Windows']
fail_msg: Unsupported installer platform. No Python/package-manager bootstrap is performed.
quiet: true
- name: Include Debian tasks
ansible.builtin.include_tasks: linux_debian.yml
when: ansible_facts['os_family'] == "Debian"
- name: Include RedHat tasks
ansible.builtin.include_tasks: linux_redhat.yml
when: ansible_facts['os_family'] == "RedHat"
- name: Include Windows tasks
ansible.builtin.include_tasks: windows.yml
when: ansible_facts['os_family'] == "Windows"
+28
View File
@@ -0,0 +1,28 @@
---
- name: Checkmk | Verify staged package on controller
ansible.builtin.stat:
path: '{{ checkmk_agent_files_dir }}/{{ checkmk_msi_filename }}'
delegate_to: localhost
become: false
register: _checkmk_package
- name: Checkmk | Require staged package
ansible.builtin.assert:
that:
- _checkmk_package.stat.isreg | default(false)
- _checkmk_package.stat.size | default(0) | int > 0
fail_msg: Required agent package is absent/empty. Use AIM Maintenance to download packages first.
quiet: true
- name: Windows | Ensure temp dir exists
ansible.windows.win_file:
path: '{{ checkmk_windows_tmp_path }}'
state: directory
- name: Windows | Copy Checkmk agent MSI from role files
ansible.windows.win_copy:
src: '{{ checkmk_agent_files_dir }}/{{ checkmk_msi_filename }}'
dest: '{{ checkmk_windows_tmp_path }}\{{ checkmk_msi_filename }}'
- name: Windows | Install Checkmk agent from local MSI
ansible.windows.win_package:
path: '{{ checkmk_windows_tmp_path }}\{{ checkmk_msi_filename }}'
state: present
register: _checkmk_package_install
+36
View File
@@ -0,0 +1,36 @@
# checkmk_cleanup_scripts
Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
```yaml
---
checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
checkmk_linux_config_dir: /etc/check_mk
checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
| default(''/etc/checkmk_monitoring_scripts'', true) }}'
checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
checkmk_unifi_mode: auto
want_linux_check_certificate: false
want_windows_citrix: false
want_windows_surebackup: false
want_windows_backup: false
want_windows_nsp_mailqueue: false
want_windows_certificate: false
want_windows_veeam_cloud_connect: false
want_windows_veeam_backup: false
checkmk_cleanup_enabled: false
```
## Structured result integration
Current reporting behavior and field semantics are specified in
[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
The calling catalog playbook owns publication; helper roles do not implicitly export
arbitrary facts, module results or debug data. Existing defaults above retain their
precedence. See the current validation/sanity documents before using the new candidate.
@@ -0,0 +1,22 @@
---
checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
checkmk_linux_config_dir: /etc/check_mk
checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
| default(''/etc/checkmk_monitoring_scripts'', true) }}'
checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
checkmk_unifi_mode: auto
want_linux_check_certificate: false
want_windows_citrix: false
want_windows_surebackup: false
want_windows_backup: false
want_windows_nsp_mailqueue: false
want_windows_certificate: false
want_windows_veeam_cloud_connect: false
want_windows_veeam_backup: false
checkmk_cleanup_enabled: false
@@ -0,0 +1,15 @@
- name: Cleanup | Remove obsolete managed local check
ansible.builtin.file:
path: '{{ checkmk_linux_local_dir }}/{{ item }}'
state: absent
loop: '{{ _checkmk_remove_paths }}'
register: _aim_cleanup_files
- name: Cleanup | Remove UniFi configuration only when UniFi is disabled
ansible.builtin.file:
path: '{{ checkmk_linux_config_dir }}/unifi.cfg'
state: absent
when: _checkmk_unifi_effective == 'disabled'
diff: false
no_log: true
register: _aim_cleanup_unifi
@@ -0,0 +1,42 @@
- name: Cleanup | Validate opt-in
ansible.builtin.assert:
that:
- (checkmk_cleanup_enabled) is boolean or (checkmk_cleanup_enabled | string | lower) in ['true',
'false']
fail_msg: checkmk_cleanup_enabled must be boolean.
quiet: true
- name: Cleanup | Build obsolete paths
ansible.builtin.set_fact:
_checkmk_remove_paths: "{{ _checkmk_obsolete_scripts | map(attribute='filename') | list }}"
- name: Cleanup | Candidate files
ansible.builtin.debug:
msg:
directory: "{{ checkmk_windows_local_dir if ansible_facts.os_family == 'Windows' else checkmk_linux_local_dir
}}"
files: '{{ _checkmk_remove_paths }}'
unifi_config: "{{ 'remove only when disabled' if ansible_facts.os_family != 'Windows' and _checkmk_unifi_effective
== 'disabled' else 'retained' }}"
mode: "{{ 'DELETE APPROVED' if checkmk_cleanup_enabled | bool else 'PREVIEW ONLY' }}"
- name: Cleanup | linux
ansible.builtin.include_tasks: linux.yml
when:
- checkmk_cleanup_enabled | bool
- ansible_facts.os_family != 'Windows'
- name: Cleanup | windows
ansible.builtin.include_tasks: windows.yml
when:
- checkmk_cleanup_enabled | bool
- ansible_facts.os_family == 'Windows'
- name: AIM | Publish operation result
ansible.builtin.set_stats:
per_host: true
aggregate: false
data:
aim_output:
protocol: aim_output_v1
schema: managed_cleanup_preview_v1
data: "{{ _checkmk_remove_paths | aim_report_cleanup(checkmk_windows_local_dir if ansible_facts.os_family
== 'Windows' else checkmk_linux_local_dir, _aim_cleanup_files | default({}), _checkmk_unifi_effective
if ansible_facts.os_family != 'Windows' else 'not_applicable', _aim_cleanup_unifi | default({}), checkmk_cleanup_enabled
| bool, ansible_check_mode) }}"
@@ -0,0 +1,36 @@
---
- name: Cleanup | Remove obsolete managed local check or legacy local plugin copy
ansible.windows.win_file:
path: '{{ checkmk_windows_local_dir }}\{{ item }}'
state: absent
loop: '{{ _checkmk_remove_paths }}'
register: _aim_cleanup_local_files
- name: Cleanup | Remove obsolete managed custom plugin
ansible.windows.win_file:
path: '{{ checkmk_windows_plugin_dir }}\{{ item }}'
state: absent
loop: >-
{{ _checkmk_obsolete_scripts
| selectattr('destination', 'defined')
| selectattr('destination', 'equalto', 'custom_plugin')
| map(attribute='filename') | list }}
register: _aim_cleanup_custom_plugin_files
- name: Cleanup | Remove obsolete known legacy built-in plugin copy
ansible.windows.win_file:
path: '{{ checkmk_windows_builtin_plugin_dir }}\{{ item }}'
state: absent
loop: >-
{{ _checkmk_remove_paths
| select('in', ['veeam_o365_status.ps1', 'veeam_backup_status.ps1'])
| list }}
register: _aim_cleanup_builtin_files
- name: Cleanup | Combine Windows cleanup results
ansible.builtin.set_fact:
_aim_cleanup_files:
results: >-
{{ (_aim_cleanup_local_files.results | default([]))
+ (_aim_cleanup_custom_plugin_files.results | default([]))
+ (_aim_cleanup_builtin_files.results | default([])) }}
+33
View File
@@ -0,0 +1,33 @@
# checkmk_configure_agent
On Windows, AIM owns only the top-level `plugins:` section of `check_mk.user.yml`.
The role preserves all other top-level sections and comments, including `global`,
`winperf`, `fileinfo`, `logwatch`, `local`, and `mrpe`.
The first line is an AIM ownership notice. The managed `plugins:` section also gets
its own ownership comment so operators can see the exact management boundary.
Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
```yaml
---
checkmk_windows_user_cfg: C:\ProgramData\checkmk\agent\check_mk.user.yml
checkmk_windows_updates_timeout: 3600
checkmk_windows_updates_cache: 43200
checkmk_mk_inventory_timeout: 120
checkmk_plugins_default_timeout: 120
checkmk_plugins_default_cache: 600
checkmk_extra_plugin_patterns: []
```
`checkmk_extra_plugin_patterns` entries are inserted before AIM's standard plugin
rules. AIM does not manage `local:` or `mrpe:` from this role; existing host-specific
configuration in those sections is left intact.
## Structured result integration
Current reporting behavior and field semantics are specified in
[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
The calling catalog playbook owns publication; helper roles do not implicitly export
arbitrary facts, module results or debug data. Existing defaults above retain their
precedence. See the current validation/sanity documents before using the new candidate.
@@ -0,0 +1,10 @@
---
# AIM owns only the top-level plugins section in the Windows user configuration.
# All other sections and comments must remain untouched.
checkmk_windows_user_cfg: C:\ProgramData\checkmk\agent\check_mk.user.yml
checkmk_windows_updates_timeout: 3600
checkmk_windows_updates_cache: 43200
checkmk_mk_inventory_timeout: 120
checkmk_plugins_default_timeout: 120
checkmk_plugins_default_cache: 600
checkmk_extra_plugin_patterns: []
@@ -0,0 +1,196 @@
---
- name: Checkmk | Validate Windows plugin execution settings
ansible.builtin.assert:
that:
- checkmk_extra_plugin_patterns is sequence
- checkmk_extra_plugin_patterns is not string
- checkmk_windows_updates_timeout | int >= 0
- checkmk_windows_updates_cache | int >= 0
- checkmk_mk_inventory_timeout | int >= 0
- checkmk_plugins_default_timeout | int >= 0
- checkmk_plugins_default_cache | int >= 0
fail_msg: Invalid Checkmk plugin execution setting type or negative timeout.
quiet: true
when: ansible_facts.os_family == 'Windows'
- name: Checkmk | Validate custom plugin rule fields
ansible.builtin.assert:
that:
- item is mapping
- item.pattern is defined
- item.pattern is string
- item.run is not defined or item.run is boolean
- item['async'] is not defined or item['async'] is boolean
- item.timeout is not defined or item.timeout | int >= 0
- item.cache_age is not defined or item.cache_age | int >= 0
- item.keys() | difference(['pattern','run','async','timeout','cache_age','retry_count']) | length == 0
fail_msg: Custom plugin rules require pattern and supported execution fields.
quiet: true
loop: '{{ checkmk_extra_plugin_patterns }}'
loop_control:
label: custom plugin execution rule
no_log: true
when: ansible_facts.os_family == 'Windows'
- name: Windows | Render AIM-managed Checkmk plugins section
ansible.windows.win_template:
src: windows_plugins_section.yml.j2
dest: '{{ checkmk_windows_user_cfg }}.aim-plugins.tmp'
when: ansible_facts.os_family == 'Windows'
diff: false
changed_when: false
- name: Windows | Replace only Checkmk plugins section
ansible.windows.win_shell: |
$ErrorActionPreference = 'Stop'
$configPath = '{{ checkmk_windows_user_cfg }}'
$fragmentPath = '{{ checkmk_windows_user_cfg }}.aim-plugins.tmp'
$header = '# Managed by Ansible (checkmk_configure_agent). Only AIM-marked sections are modified; all other content is preserved.'
$legacyHeader = '# Managed by Ansible (checkmk_configure_agent).'
$managedMarkerPrefix = '# AIM managed section: plugins (checkmk_configure_agent).'
if (-not (Test-Path -LiteralPath $fragmentPath)) {
throw "AIM Checkmk plugins fragment is missing: $fragmentPath"
}
$fragment = [System.IO.File]::ReadAllText($fragmentPath)
$fragment = $fragment.TrimEnd([char[]]"`r`n")
if (Test-Path -LiteralPath $configPath) {
$original = [System.IO.File]::ReadAllText($configPath)
}
else {
$original = ''
}
if ($original.Contains("`r`n")) {
$newline = "`r`n"
}
else {
$newline = "`n"
}
$hadFinalNewline = $original.EndsWith("`r`n") -or $original.EndsWith("`n") -or $original.EndsWith("`r")
$lines = @()
if ($original.Length -gt 0) {
$lines = @([regex]::Split($original, "`r`n|`n|`r"))
if ($hadFinalNewline -and $lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') {
if ($lines.Count -eq 1) {
$lines = @()
}
else {
$lines = @($lines[0..($lines.Count - 2)])
}
}
}
# Keep the AIM ownership notice as line 1 without discarding the previous first line.
if ($lines.Count -eq 0) {
$lines = @($header)
}
elseif ($lines[0] -eq $legacyHeader -or $lines[0].StartsWith('# Managed by Ansible (checkmk_configure_agent).')) {
$lines[0] = $header
}
else {
$lines = @($header) + $lines
}
$pluginIndex = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^plugins\s*:\s*(?:#.*)?$') {
$pluginIndex = $i
break
}
}
$fragmentLines = @([regex]::Split($fragment, "`r`n|`n|`r"))
if ($pluginIndex -ge 0) {
$replaceStart = $pluginIndex
if ($pluginIndex -gt 0 -and $lines[$pluginIndex - 1].StartsWith($managedMarkerPrefix)) {
$replaceStart = $pluginIndex - 1
}
$nextTopLevelKey = $lines.Count
for ($i = $pluginIndex + 1; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^[A-Za-z_][A-Za-z0-9_.-]*\s*:') {
$nextTopLevelKey = $i
break
}
}
# Preserve blank lines and top-level comments immediately before the next untouched section.
$replaceEnd = $nextTopLevelKey
while ($replaceEnd -gt ($pluginIndex + 1)) {
$candidate = $lines[$replaceEnd - 1]
if ([string]::IsNullOrWhiteSpace($candidate) -or $candidate.StartsWith('#')) {
$replaceEnd--
}
else {
break
}
}
$before = @()
if ($replaceStart -gt 0) {
$before = @($lines[0..($replaceStart - 1)])
}
$after = @()
if ($replaceEnd -lt $lines.Count) {
$after = @($lines[$replaceEnd..($lines.Count - 1)])
}
$lines = @($before + $fragmentLines + $after)
}
else {
if ($lines.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($lines[$lines.Count - 1])) {
$lines += ''
}
$lines += $fragmentLines
}
$updated = [string]::Join($newline, $lines)
if ($hadFinalNewline -or $original.Length -eq 0) {
$updated += $newline
}
if ($updated -ne $original) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($configPath, $updated, $utf8NoBom)
Write-Output 'AIM_CHANGED=true'
}
else {
Write-Output 'AIM_CHANGED=false'
}
register: _checkmk_plugins_update
changed_when: "'AIM_CHANGED=true' in _checkmk_plugins_update.stdout"
when: ansible_facts.os_family == 'Windows'
notify: checkmk | windows | configuration-changed
diff: false
- name: Windows | Normalize ACL on Checkmk user configuration
ansible.builtin.include_role:
name: checkmk_windows_acl
vars:
checkmk_windows_acl_paths:
- '{{ checkmk_windows_user_cfg }}'
when: ansible_facts.os_family == 'Windows'
- name: Windows | Remove temporary Checkmk plugins fragment
ansible.windows.win_file:
path: '{{ checkmk_windows_user_cfg }}.aim-plugins.tmp'
state: absent
when: ansible_facts.os_family == 'Windows'
changed_when: false
- name: Checkmk | Configuration summary
ansible.builtin.debug:
msg:
destination: '{{ checkmk_windows_user_cfg }}'
managed_section: plugins
extra_plugin_rules: '{{ checkmk_extra_plugin_patterns | length }}'
other_sections: preserved
when:
- ansible_facts.os_family == 'Windows'
- aim_debug | default(false) | bool
@@ -0,0 +1,72 @@
# AIM managed section: plugins (checkmk_configure_agent). Content under plugins: may be replaced by AIM.
plugins:
execution:
{% if checkmk_extra_plugin_patterns | length %}
{{ checkmk_extra_plugin_patterns | to_nice_yaml(indent=2, sort_keys=false) | indent(4, true) }}
{% endif %}
- pattern: '$BUILTIN_PLUGINS_PATH$\windows_updates.vbs'
run: true
async: true
timeout: {{ checkmk_windows_updates_timeout | int }}
cache_age: {{ checkmk_windows_updates_cache | int }}
retry_count: 0
- pattern: '$BUILTIN_PLUGINS_PATH$\mk_inventory.vbs'
run: true
async: true
timeout: {{ checkmk_mk_inventory_timeout | int }}
cache_age: 3600
- pattern: '$BUILTIN_PLUGINS_PATH$\mssql.vbs'
run: true
timeout: {{ checkmk_plugins_default_timeout | int }}
{% if has_veeam_vbo | default(false) %}
- pattern: '$CUSTOM_PLUGINS_PATH$\veeam_o365_status.ps1'
run: true
async: true
timeout: {{ checkmk_plugins_default_timeout | int }}
cache_age: {{ checkmk_plugins_default_cache | int }}
{% endif %}
{% if want_windows_citrix | default(false) %}
- pattern: '$CUSTOM_PLUGINS_PATH$\citrix_sessions_customized.ps1'
run: true
async: true
timeout: {{ checkmk_plugins_default_timeout | int }}
cache_age: {{ checkmk_plugins_default_cache | int }}
{% endif %}
{% if want_windows_veeam_backup | default(false) %}
- pattern: '$CUSTOM_PLUGINS_PATH$\veeam_backup_status.ps1'
run: true
async: true
timeout: {{ checkmk_plugins_default_timeout | int }}
cache_age: {{ checkmk_plugins_default_cache | int }}
{% endif %}
{% if is_dc | default(false) %}
- pattern: '$BUILTIN_PLUGINS_PATH$\ad_replication.bat'
run: true
timeout: {{ checkmk_plugins_default_timeout | int }}
{% endif %}
{% if is_dhcp_server | default(false) %}
- pattern: '$BUILTIN_PLUGINS_PATH$\win_dhcp_pools.bat'
run: true
timeout: {{ checkmk_plugins_default_timeout | int }}
{% endif %}
{% if is_hyperv_host | default(false) %}
- pattern: '$BUILTIN_PLUGINS_PATH$\hyperv_vms.ps1'
run: true
timeout: {{ checkmk_plugins_default_timeout | int }}
- pattern: '$BUILTIN_PLUGINS_PATH$\hyperv_vms_guestinfos.ps1'
run: true
timeout: {{ checkmk_plugins_default_timeout | int }}
{% endif %}
- pattern: '$CUSTOM_PLUGINS_PATH$\*.ps1'
run: true
async: true
timeout: {{ checkmk_plugins_default_timeout | int }}
cache_age: {{ checkmk_plugins_default_cache | int }}
- pattern: '$CUSTOM_PLUGINS_PATH$\*.*'
run: true
timeout: {{ checkmk_plugins_default_timeout | int }}
- pattern: '$BUILTIN_PLUGINS_PATH$\*.*'
run: false
timeout: {{ checkmk_plugins_default_timeout | int }}
- pattern: '*'
run: false
+48
View File
@@ -0,0 +1,48 @@
# checkmk_deploy_scripts
Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
```yaml
---
# unifi.cfg is role-managed and replaced, mode 0600. Password comes from Vault.
# An absent/CHANGEME password fails before deployment; no credentials are logged.
checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
checkmk_linux_config_dir: /etc/check_mk
checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
| default(''/etc/checkmk_monitoring_scripts'', true) }}'
checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
checkmk_unifi_mode: auto
want_linux_check_certificate: false
want_windows_citrix: false
want_windows_surebackup: false
want_windows_backup: false
want_windows_nsp_mailqueue: false
want_windows_certificate: false
want_windows_veeam_cloud_connect: false
want_windows_veeam_backup: false
checkmk_unifi_username: bf-monitoring
checkmk_unifi_password: '{{ vault_checkmk_unifi_password | default('''') }}'
checkmk_unifi_baseurl: '{{ ''https://127.0.0.1:11443'' if _checkmk_unifi_effective == ''os'' else ''https://127.0.0.1:8443''
}}'
checkmk_unifi_curl_options: ' --insecure --tlsv1.2'
checkmk_unifi_status_provisioning: 1
checkmk_unifi_status_upgrading: 1
checkmk_unifi_status_upgradable: 0
checkmk_unifi_status_heartbeat_missed: 1
checkmk_unifi_status_noautobackup: 0
```
`unifi.cfg` is generated from the supplied schema, POSIX-shell quoted, mode `0600`, and protected with `no_log` and `diff: false`. Use a Vault reference for the password. `CHANGEME` and empty passwords fail before deployment. The active UniFi mode replaces the alternative local check. Other obsolete scripts are removed only by explicit cleanup.
## Structured result integration
Current reporting behavior and field semantics are specified in
[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
The calling catalog playbook owns publication; helper roles do not implicitly export
arbitrary facts, module results or debug data. Existing defaults above retain their
precedence. See the current validation/sanity documents before using the new candidate.
@@ -0,0 +1,32 @@
---
# unifi.cfg is role-managed and replaced, mode 0600. Password comes from Vault.
# An absent/CHANGEME password fails before deployment; no credentials are logged.
checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
checkmk_linux_config_dir: /etc/check_mk
checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
| default(''/etc/checkmk_monitoring_scripts'', true) }}'
checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
checkmk_unifi_mode: auto
want_linux_check_certificate: false
want_windows_citrix: false
want_windows_surebackup: false
want_windows_backup: false
want_windows_nsp_mailqueue: false
want_windows_certificate: false
want_windows_veeam_cloud_connect: false
want_windows_veeam_backup: false
checkmk_unifi_username: bf-monitoring
checkmk_unifi_password: '{{ vault_checkmk_unifi_password | default('''') }}'
checkmk_unifi_baseurl: '{{ ''https://127.0.0.1:11443'' if _checkmk_unifi_effective == ''os'' else ''https://127.0.0.1:8443''
}}'
checkmk_unifi_curl_options: ' --insecure --tlsv1.2'
checkmk_unifi_status_provisioning: 1
checkmk_unifi_status_upgrading: 1
checkmk_unifi_status_upgradable: 0
checkmk_unifi_status_heartbeat_missed: 1
checkmk_unifi_status_noautobackup: 0
@@ -0,0 +1,40 @@
# UniFi mode replacement is the explicitly approved exception to separate cleanup.
# Unknown files and the active UniFi check are never removed here.
- name: Linux | Ensure local check directory
ansible.builtin.file:
path: '{{ checkmk_linux_local_dir }}'
state: directory
mode: '0755'
- name: Linux | Ensure configuration directory
ansible.builtin.file:
path: '{{ checkmk_linux_config_dir }}'
state: directory
mode: '0755'
- name: Linux | Write the single UniFi configuration
ansible.builtin.template:
src: unifi.cfg.j2
dest: '{{ checkmk_linux_config_dir }}/unifi.cfg'
owner: root
group: root
mode: '0600'
validate: /bin/sh -n %s
when: _checkmk_unifi_effective in ['network','os']
no_log: true
diff: false
register: _aim_unifi_write
- name: Linux | Deploy selected monitoring checks
ansible.builtin.copy:
src: '{{ item.source }}'
dest: '{{ checkmk_linux_local_dir }}/{{ item.filename }}'
mode: '0755'
loop: '{{ _checkmk_selected_scripts }}'
loop_control:
label: '{{ item.filename }}'
register: _aim_check_copies
- name: Linux | Remove only the opposite UniFi local check
ansible.builtin.file:
path: "{{ checkmk_linux_local_dir }}/{{ 'check_unifi-controller.sh' if _checkmk_unifi_effective == 'os' else
'check_unifi-os.sh' }}"
state: absent
when: _checkmk_unifi_effective in ['network','os']
register: _aim_opposite_remove
@@ -0,0 +1,10 @@
---
- name: Checkmk | Validate controller sources and required parameters
ansible.builtin.import_tasks: preflight.yml
- name: Checkmk | Deploy linux checks
ansible.builtin.include_tasks: linux.yml
when: ansible_facts.os_family != 'Windows'
- name: Checkmk | Deploy windows checks
ansible.builtin.include_tasks: windows.yml
when: ansible_facts.os_family == 'Windows'
@@ -0,0 +1,57 @@
---
- name: Checkmk | Inspect selected controller script sources
ansible.builtin.stat:
path: '{{ item.source }}'
delegate_to: localhost
become: false
loop: '{{ _checkmk_selected_scripts }}'
loop_control:
label: '{{ item.filename }}'
register: _checkmk_sources
- name: Checkmk | Require selected controller files
ansible.builtin.assert:
that:
- item.stat.exists | default(false)
- item.stat.isreg | default(false)
fail_msg: A selected monitoring script is missing on the controller. Sync the monitoring repository first.
quiet: true
loop: '{{ _checkmk_sources.results }}'
loop_control:
label: '{{ item.item.filename }}'
- name: Checkmk | Validate UniFi public settings
ansible.builtin.assert:
that:
- checkmk_unifi_username is string
- checkmk_unifi_username | length > 0
- checkmk_unifi_baseurl is match('^https?://[^\s]+$')
- checkmk_unifi_curl_options is string
- checkmk_unifi_status_provisioning | int in [0,1,2,3]
- checkmk_unifi_status_upgrading | int in [0,1,2,3]
- checkmk_unifi_status_upgradable | int in [0,1,2,3]
- checkmk_unifi_status_heartbeat_missed | int in [0,1,2,3]
- checkmk_unifi_status_noautobackup | int in [0,1,2,3]
fail_msg: Invalid UniFi username, base URL, curl options or status mapping.
quiet: true
when:
- ansible_facts.os_family != 'Windows'
- _checkmk_unifi_effective in ['network','os']
- name: Checkmk | Require a real UniFi monitoring password
when:
- ansible_facts.os_family != 'Windows'
- _checkmk_unifi_effective in ['network','os']
block:
- name: Checkmk | Validate secret
ansible.builtin.assert:
that:
- checkmk_unifi_password is string
- checkmk_unifi_password | length > 0
- checkmk_unifi_password != 'CHANGEME'
fail_msg: A real UniFi password is required.
quiet: true
no_log: true
rescue:
- name: Checkmk | Explain missing UniFi secret
ansible.builtin.fail:
msg: Set vault_checkmk_unifi_password in the customer Vault, or override checkmk_unifi_password with
a host-specific Vault reference. Empty values and CHANGEME are refused. No secret was logged.
@@ -0,0 +1,68 @@
---
- name: Windows | Ensure Checkmk local directory
ansible.windows.win_file:
path: '{{ checkmk_windows_local_dir }}'
state: directory
- name: Windows | Ensure Checkmk custom plugin directory
ansible.windows.win_file:
path: '{{ checkmk_windows_plugin_dir }}'
state: directory
when: >-
{{ _checkmk_selected_scripts
| selectattr('destination', 'defined')
| selectattr('destination', 'equalto', 'custom_plugin')
| list | length > 0 }}
- name: Windows | Deploy selected monitoring checks
ansible.windows.win_copy:
src: '{{ item.source }}'
dest: >-
{{ (checkmk_windows_plugin_dir
if item.destination | default('local') == 'custom_plugin'
else checkmk_windows_local_dir) }}\{{ item.filename }}
loop: '{{ _checkmk_selected_scripts }}'
loop_control:
label: '{{ item.filename }}'
register: _aim_check_copies
- name: Windows | Normalize ACL on AIM-managed monitoring checks
ansible.builtin.include_role:
name: checkmk_windows_acl
vars:
checkmk_windows_acl_paths:
- >-
{{ (checkmk_windows_plugin_dir
if checkmk_acl_script.destination | default('local') == 'custom_plugin'
else checkmk_windows_local_dir) }}\{{ checkmk_acl_script.filename }}
loop: '{{ _checkmk_selected_scripts }}'
loop_control:
loop_var: checkmk_acl_script
label: '{{ checkmk_acl_script.filename }}'
- name: Windows | Remove legacy local copies after custom plugin relocation
ansible.windows.win_file:
path: '{{ checkmk_windows_local_dir }}\{{ item.filename }}'
state: absent
loop: >-
{{ _checkmk_selected_scripts
| selectattr('destination', 'defined')
| selectattr('destination', 'equalto', 'custom_plugin')
| list }}
loop_control:
label: '{{ item.filename }}'
register: _aim_custom_plugin_legacy_local_remove
- name: Windows | Remove known legacy built-in copies after custom plugin relocation
ansible.windows.win_file:
path: '{{ checkmk_windows_builtin_plugin_dir }}\{{ item.filename }}'
state: absent
loop: >-
{{ _checkmk_selected_scripts
| selectattr('destination', 'defined')
| selectattr('destination', 'equalto', 'custom_plugin')
| selectattr('filename', 'in', ['veeam_o365_status.ps1', 'veeam_backup_status.ps1'])
| list }}
loop_control:
label: '{{ item.filename }}'
register: _aim_custom_plugin_legacy_builtin_remove
@@ -0,0 +1,13 @@
# Managed by Ansible - checkmk_deploy_scripts. One UniFi variant per host.
# Values are POSIX-shell quoted because the monitoring scripts source this file.
USERNAME={{ checkmk_unifi_username | quote }}
PASSWORD={{ checkmk_unifi_password | quote }}
BASEURL={{ checkmk_unifi_baseurl | quote }}
CURLOPTS={{ checkmk_unifi_curl_options | quote }}
# 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN
STATUS_PROVISIONING={{ checkmk_unifi_status_provisioning | int }}
STATUS_UPGRADING={{ checkmk_unifi_status_upgrading | int }}
STATUS_UPGRADABLE={{ checkmk_unifi_status_upgradable | int }}
STATUS_HEARTBEAT_MISSED={{ checkmk_unifi_status_heartbeat_missed | int }}
STATUS_NOAUTOBACKUP={{ checkmk_unifi_status_noautobackup | int }}
+23
View File
@@ -0,0 +1,23 @@
# checkmk_manage_service
Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
```yaml
---
checkmk_linux_service_name: cmk-agent-ctl-daemon.service
checkmk_linux_socket_name: check-mk-agent.socket
checkmk_windows_service_name: ''
checkmk_windows_service_candidates:
- Check_MK_Agent
- CheckmkService
- Checkmk Service
```
## Structured result integration
Current reporting behavior and field semantics are specified in
[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
The calling catalog playbook owns publication; helper roles do not implicitly export
arbitrary facts, module results or debug data. Existing defaults above retain their
precedence. See the current validation/sanity documents before using the new candidate.
@@ -0,0 +1,9 @@
---
checkmk_linux_service_name: cmk-agent-ctl-daemon.service
checkmk_linux_socket_name: check-mk-agent.socket
checkmk_windows_service_name: ''
checkmk_windows_service_candidates:
- Check_MK_Agent
- CheckmkService
- Checkmk Service
@@ -0,0 +1,9 @@
---
- name: Checkmk | Restart changed Windows agent configuration
ansible.windows.win_service:
name: '{{ item }}'
state: restarted
listen: checkmk | windows | configuration-changed
loop: '{{ _checkmk_windows_services | default([]) }}'
when: ansible_facts.os_family == 'Windows'
@@ -0,0 +1,42 @@
---
- name: Linux | Require systemd service management
ansible.builtin.assert:
that:
- ansible_facts.service_mgr == 'systemd'
fail_msg: This Checkmk service policy requires systemd. No alternate service system is configured.
quiet: true
- name: Linux | Refresh systemd after package installation
ansible.builtin.systemd_service:
daemon_reload: true
when: _checkmk_package_install.changed | default(false) | bool
- name: Linux | Detect configured Checkmk units independently of running state
ansible.builtin.command:
argv:
- systemctl
- show
- --property=LoadState
- --value
- '{{ item }}'
loop:
- '{{ checkmk_linux_socket_name }}'
- '{{ checkmk_linux_service_name }}'
register: _checkmk_units
changed_when: false
failed_when: 'false'
check_mode: false
- name: Linux | Select the installed unit, preferring the socket
ansible.builtin.set_fact:
_checkmk_linux_units: '{{ _checkmk_units.results | selectattr(''stdout'', ''defined'') | selectattr(''stdout'',
''equalto'', ''loaded'') | map(attribute=''item'') | list }}'
- name: Linux | Require an installed Checkmk unit
ansible.builtin.assert:
that:
- _checkmk_linux_units | length > 0
fail_msg: No configured Checkmk socket/service unit was found. Verify installation and the unit names.
quiet: true
- name: Linux | Ensure Checkmk is enabled and running
ansible.builtin.systemd_service:
name: '{{ _checkmk_linux_units | first }}'
enabled: true
state: started
@@ -0,0 +1,8 @@
---
- name: Checkmk | Ensure agent service on linux
ansible.builtin.include_tasks: linux.yml
when: ansible_facts.os_family != 'Windows'
- name: Checkmk | Ensure agent service on windows
ansible.builtin.include_tasks: windows.yml
when: ansible_facts.os_family == 'Windows'
@@ -0,0 +1,24 @@
---
- name: Windows | Find installed Checkmk service
ansible.windows.win_service_info:
name: '{{ item }}'
loop: '{{ ([checkmk_windows_service_name] if checkmk_windows_service_name | length else checkmk_windows_service_candidates)
}}'
register: _checkmk_win_service_query
- name: Windows | Record service names
ansible.builtin.set_fact:
_checkmk_windows_services: '{{ _checkmk_win_service_query.results | selectattr(''services'', ''defined'')
| map(attribute=''services'') | flatten | map(attribute=''name'') | unique | list }}'
- name: Windows | Require installed Checkmk service
ansible.builtin.assert:
that:
- _checkmk_windows_services | length > 0
fail_msg: No Checkmk service was found. Check the installed package or set checkmk_windows_service_name.
quiet: true
- name: Windows | Ensure Checkmk service is running
ansible.windows.win_service:
name: '{{ item }}'
start_mode: auto
state: started
loop: '{{ _checkmk_windows_services }}'
+9
View File
@@ -0,0 +1,9 @@
# checkmk_report
Reporting-only helper for the Checkmk installation playbooks. Queries installed version
from Windows uninstall registry or Debian/RPM package database, and re-reads current
service/unit state. Does not install packages or restart/configure services. Unknown or
ambiguous versions are null, never inferred from the staged package filename.
The calling playbook publishes checkmk_agent_state_v1 via set_stats. See
[OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+79
View File
@@ -0,0 +1,79 @@
- name: Checkmk | Collect managed change summary
ansible.builtin.set_fact:
_aim_checkmk_changes: '{{ _aim_check_copies | default({}) | aim_report_checkmk_changes(_checkmk_selected_scripts,
_checkmk_plugins_update | default({}), _aim_unifi_write | default({}), _aim_opposite_remove | default({}),
_checkmk_unifi_effective, ansible_check_mode) }}'
# No supported Windows package-inventory module exposes arbitrary installed MSI/registry products.
# Keep this bounded read-only registry query until an official module covers that data.
- name: Checkmk | Query Windows installed version
ansible.windows.win_shell: |
$ErrorActionPreference = 'Stop'
$roots = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')
$products = @(Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -match '^(Check_MK|Checkmk|Check MK).*Agent' })
$versions = @($products | Select-Object -ExpandProperty DisplayVersion -Unique)
$version = $null
if ($versions.Count -eq 1) { $version = [string]$versions[0] }
@{ installed=($products.Count -gt 0); version=$version; version_source='registry' } | ConvertTo-Json -Compress
register: _aim_checkmk_version_raw
changed_when: false
check_mode: false
when: ansible_facts.os_family == 'Windows'
- name: Checkmk | Collect Linux package facts
ansible.builtin.package_facts:
manager: auto
when: ansible_facts.os_family != 'Windows'
- name: Checkmk | Record Linux installed package rows
ansible.builtin.set_fact:
_aim_checkmk_linux_package_rows: "{{ ansible_facts.packages.get('check-mk-agent', []) }}"
when: ansible_facts.os_family != 'Windows'
- name: Checkmk | Observe Windows service state
ansible.windows.win_service_info:
name: '{{ item }}'
loop: '{{ _checkmk_windows_services | default([]) }}'
register: _aim_checkmk_final_services
when: ansible_facts.os_family == 'Windows'
- name: Checkmk | Collect Linux service facts
ansible.builtin.service_facts:
when: ansible_facts.os_family != 'Windows'
- name: Checkmk | Observe Linux unit state
ansible.builtin.set_fact:
_aim_checkmk_unit_state: >-
{{ ansible_facts.services.get(_checkmk_linux_units | first,
{'name': _checkmk_linux_units | first, 'state': 'unknown'}) }}
when: ansible_facts.os_family != 'Windows'
- name: Checkmk | Build installed state report
ansible.builtin.set_fact:
_aim_checkmk_state: >-
{{
(
(_aim_checkmk_version_raw.stdout | from_json)
if ansible_facts.os_family == 'Windows'
else {
'installed': (_aim_checkmk_linux_package_rows | default([]) | length) > 0,
'version': (
(_aim_checkmk_linux_package_rows | first).version
if (_aim_checkmk_linux_package_rows | default([]) | length) == 1
else none
),
'version_source': 'package_facts'
}
)
| aim_report_checkmk_state(
(
_aim_checkmk_final_services.results
| selectattr('services', 'defined')
| map(attribute='services')
| flatten
) if ansible_facts.os_family == 'Windows' else [_aim_checkmk_unit_state],
_aim_checkmk_changes,
_checkmk_package_install.changed | default(false),
ansible_check_mode
)
}}
+27
View File
@@ -0,0 +1,27 @@
# checkmk_script_plan
Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
```yaml
---
# Shared deployment and cleanup paths/optional switches. No secrets.
checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
checkmk_linux_config_dir: /etc/check_mk
checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
| default(''/etc/checkmk_monitoring_scripts'', true) }}'
checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
checkmk_unifi_mode: auto
want_linux_check_certificate: false
want_windows_citrix: false
want_windows_surebackup: false
want_windows_backup: false
want_windows_nsp_mailqueue: false
want_windows_certificate: false
want_windows_veeam_cloud_connect: false
want_windows_veeam_backup: false
```
@@ -0,0 +1,21 @@
---
# Shared deployment and cleanup paths/optional switches. No secrets.
checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
checkmk_linux_config_dir: /etc/check_mk
checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
| default(''/etc/checkmk_monitoring_scripts'', true) }}'
checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
checkmk_unifi_mode: auto
want_linux_check_certificate: false
want_windows_citrix: false
want_windows_surebackup: false
want_windows_backup: false
want_windows_nsp_mailqueue: false
want_windows_certificate: false
want_windows_veeam_cloud_connect: false
want_windows_veeam_backup: false
+27
View File
@@ -0,0 +1,27 @@
---
- name: Checkmk | Validate UniFi mode
ansible.builtin.assert:
that:
- checkmk_unifi_mode in ['auto','network','os','disabled']
fail_msg: UniFi mode must be auto, network, os or disabled.
quiet: true
- name: Checkmk | Resolve UniFi mode
ansible.builtin.set_fact:
_checkmk_unifi_effective: '{{ checkmk_unifi_mode if checkmk_unifi_mode != ''auto'' else (''os'' if is_unifi_os_server
| default(false) | bool else (''network'' if is_unifi_controller | default(false) | bool else ''disabled''))
}}'
- name: Checkmk | Build managed script plan
ansible.builtin.set_fact:
_checkmk_script_catalog: '{{ _checkmk_windows_catalog if ansible_facts.os_family == ''Windows'' else _checkmk_linux_catalog
}}'
- name: Checkmk | Resolve selected and obsolete checks
ansible.builtin.set_fact:
_checkmk_selected_scripts: '{{ _checkmk_script_catalog | selectattr(''enabled'') | list }}'
_checkmk_obsolete_scripts: '{{ _checkmk_script_catalog | rejectattr(''enabled'') | list }}'
- name: Checkmk | Selected check plan
ansible.builtin.debug:
msg:
files: '{{ _checkmk_selected_scripts | map(attribute=''filename'') | list }}'
unifi_mode: '{{ _checkmk_unifi_effective }}'
when: aim_debug | default(false) | bool
+49
View File
@@ -0,0 +1,49 @@
---
# Internal managed filenames. Repository scripts are not embedded or rewritten by AIM.
_checkmk_windows_catalog:
- filename: check-ping.ps1
source: '{{ checkmk_windows_scripts_dir }}/check-ping.ps1'
enabled: '{{ is_dc | default(false) | bool }}'
- filename: veeam_config_backup_status.ps1
source: '{{ checkmk_windows_scripts_dir }}/veeam_config_backup_status.ps1'
enabled: '{{ has_veeam_vbr | default(false) | bool }}'
- filename: veeam_backup_license_status.ps1
source: '{{ checkmk_windows_scripts_dir }}/veeam_backup_license_status.ps1'
enabled: '{{ has_veeam_vbr | default(false) | bool }}'
- filename: veeam_o365_status.ps1
source: '{{ checkmk_windows_scripts_dir }}/veeam_o365_status.ps1'
destination: custom_plugin
enabled: '{{ has_veeam_vbo | default(false) | bool }}'
- filename: citrix_sessions_customized.ps1
source: '{{ checkmk_windows_scripts_dir }}/citrix_sessions_customized.ps1'
destination: custom_plugin
enabled: '{{ want_windows_citrix | default(false) | bool }}'
- filename: veeam_surebackup_status.ps1
source: '{{ checkmk_windows_scripts_dir }}/veeam_surebackup_status.ps1'
enabled: '{{ want_windows_surebackup | default(false) | bool }}'
- filename: windows-backup.ps1
source: '{{ checkmk_windows_scripts_dir }}/windows-backup.ps1'
enabled: '{{ want_windows_backup | default(false) | bool }}'
- filename: check-nsp-mailqueue.ps1
source: '{{ checkmk_windows_scripts_dir }}/check-nsp-mailqueue.ps1'
enabled: '{{ want_windows_nsp_mailqueue | default(false) | bool }}'
- filename: win_check_cert.ps1
source: '{{ checkmk_windows_scripts_dir }}/win_check_cert.ps1'
enabled: '{{ want_windows_certificate | default(false) | bool }}'
- filename: veeam_cloud_connect_status.ps1
source: '{{ checkmk_windows_scripts_dir }}/veeam_cloud_connect_status.ps1'
enabled: '{{ want_windows_veeam_cloud_connect | default(false) | bool }}'
- filename: veeam_backup_status.ps1
source: '{{ checkmk_windows_scripts_dir }}/veeam_backup_status.ps1'
destination: custom_plugin
enabled: '{{ want_windows_veeam_backup | default(false) | bool }}'
_checkmk_linux_catalog:
- filename: check_unifi-controller.sh
source: '{{ checkmk_linux_scripts_dir }}/check_unifi-controller/check_unifi-controller.sh'
enabled: '{{ _checkmk_unifi_effective == ''network'' }}'
- filename: check_unifi-os.sh
source: '{{ checkmk_linux_scripts_dir }}/check_unifi-controller/check_unifi-os.sh'
enabled: '{{ _checkmk_unifi_effective == ''os'' }}'
- filename: check_certificate_directory.sh
source: '{{ checkmk_linux_scripts_dir }}/check_certificate_directory.sh'
enabled: '{{ want_linux_check_certificate | default(false) | bool }}'
+16
View File
@@ -0,0 +1,16 @@
# checkmk_windows_acl
Normalizes access on AIM-managed persistent Windows Checkmk files without recursively
changing Checkmk directories or unknown/operator files.
The role enables parent ACL inheritance and guarantees locale-independent well-known
principals by SID:
- SYSTEM (`S-1-5-18`): FullControl
- local Administrators (`S-1-5-32-544`): FullControl
- ALL APPLICATION PACKAGES (`S-1-15-2-1`): ReadAndExecute
- ALL RESTRICTED APPLICATION PACKAGES (`S-1-15-2-2`): ReadAndExecute
AIM does not add customer-specific administrator/user ACEs. Existing intentional parent
or explicit ACEs are not blindly purged. Pass persistent AIM-owned file paths through
`checkmk_windows_acl_paths`.

Some files were not shown because too many files have changed in this diff Show More