added aim

This commit is contained in:
admin_rb
2026-09-15 18:54:22 +02:00
parent 343fe9b22f
commit 7c24c7ba7f
44 changed files with 4742 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "aim-inventory-manager"
version = "2.1.2"
description = "AIM - Ansible Inventory Manager"
requires-python = ">=3.11"
dependencies = [
"ruamel.yaml>=0.18,<0.19",
"rich>=13,<15",
]
[project.scripts]
aim = "aim.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]
+1
View File
@@ -0,0 +1 @@
__version__ = "2.1.2"
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import argparse
import signal
import sys
from aim import __version__
def main() -> None:
parser = argparse.ArgumentParser(prog='aim', description='AIM - Ansible Inventory Manager')
parser.add_argument('--version', action='version', version=f'AIM {__version__}')
parser.add_argument('--no-clear', action='store_true', help='Keep terminal scrollback between menus')
parser.add_argument('--plain', action='store_true', help='Use ASCII characters for limited terminals')
parser.add_argument('--live-output', action='store_true', help='Stream all external command output')
args = parser.parse_args()
from aim.exceptions import AIMError
from aim.ui.app import App
from ruamel.yaml.error import YAMLError
old_term = signal.getsignal(signal.SIGTERM)
def terminate(signum, frame):
# Unwind context managers so temporary files and AIM-owned agents clean up.
raise SystemExit(128 + signum)
signal.signal(signal.SIGTERM, terminate)
try:
try:
app = App(no_clear=args.no_clear, plain=args.plain, live_output=args.live_output)
except (AIMError, OSError, ValueError, YAMLError) as exc:
print(f'AIM startup error ({type(exc).__name__}). Check aim.yml and access permissions.', file=sys.stderr)
raise SystemExit(1)
try:
raise SystemExit(app.run())
except KeyboardInterrupt:
raise SystemExit(130) from None
finally:
signal.signal(signal.SIGTERM, old_term)
if __name__ == '__main__':
main()
+41
View File
@@ -0,0 +1,41 @@
import grp
import os
from dataclasses import dataclass
from aim.exceptions import AIMError
@dataclass(frozen=True)
class AuthorizationResult:
group_name: str
gid: int
class AuthorizationError(AIMError):
pass
def require_group_membership(group_name: str) -> AuthorizationResult:
"""Require membership in the configured local/NSS-resolved group.
The group may be provided by local files, SSSD, winbind, or another NSS
provider. Both the process primary GID and supplementary groups are checked.
"""
try:
group = grp.getgrnam(group_name)
except KeyError as exc:
raise AuthorizationError(
f"Required AIM group could not be resolved: {group_name!r}. "
"Check NSS/SSSD/winbind and the AIM configuration."
) from exc
required_gid = group.gr_gid
active_gids = {os.getgid(), os.getegid(), *os.getgroups()}
if required_gid not in active_gids:
raise AuthorizationError(
f"AIM requires membership in {group_name!r} (GID {required_gid}). "
"Your current session does not have that group active."
)
return AuthorizationResult(group_name=group_name, gid=required_gid)
View File
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import os
import shutil
import stat
import tempfile
from pathlib import Path
from aim.inventory.validator import validate_yaml
from aim.locking import InventoryLock
class SessionBackupManager:
"""Keep one pre-change recovery copy per inventory for this AIM process."""
def __init__(self):
self._backed_up: set[Path] = set()
@staticmethod
def backup_path(inventory: Path) -> Path:
return inventory.with_name(inventory.stem + ".aim-session.bak" + inventory.suffix)
def backup_once(self, inventory: Path) -> Path | None:
inventory = inventory.resolve()
if inventory in self._backed_up or not inventory.exists():
return None
backup = self.backup_path(inventory)
shutil.copy2(inventory, backup)
self._backed_up.add(inventory)
return backup
def has_session_backup(self, inventory: Path) -> bool:
inventory = inventory.resolve()
return inventory in self._backed_up and self.backup_path(inventory).exists()
def restore(self, inventory: Path, *, customer: str) -> Path:
inventory = inventory.resolve()
if not self.has_session_backup(inventory):
raise FileNotFoundError("No recovery backup was created for this inventory in the current AIM session")
backup = self.backup_path(inventory)
validate_yaml(backup)
with InventoryLock(inventory):
fd, temp_name = tempfile.mkstemp(prefix=".aim-restore-", suffix=".yml", dir=inventory.parent)
os.close(fd)
temp = Path(temp_name)
try:
shutil.copy2(backup, temp)
if inventory.exists():
temp.chmod(stat.S_IMODE(inventory.stat().st_mode))
os.replace(temp, inventory)
finally:
temp.unlink(missing_ok=True)
validate_yaml(inventory)
return backup
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from dataclasses import dataclass, field
import os
import tempfile
from pathlib import Path
from ruamel.yaml import YAML
from aim.exceptions import ConfigurationError
from aim.permissions import apply_managed_permissions
DEFAULT_REQUIRED_GROUP = "srv_debsansible01_admins@bitformer.lan"
DEFAULT_PLATFORMS = ["linux", "windows", "sophosxgs", "pfsense"]
@dataclass
class Config:
root_dir: Path = Path("/etc/ansible")
service_user: str = "svc_bf-ansible"
required_group: str = DEFAULT_REQUIRED_GROUP
platform_groups: list[str] = field(default_factory=lambda: list(DEFAULT_PLATFORMS))
ui_clear_screen: bool = True
ui_ascii: bool = False
ui_output: str = "compact"
@property
def inventory_dir(self) -> Path:
return self.root_dir / "inventories"
@property
def config_path(self) -> Path:
return self.root_dir / "aim.yml"
@classmethod
def load(cls, path: Path | None = None) -> "Config":
default = cls()
path = path or default.config_path
if not path.exists():
return default
try:
yaml = YAML(typ="safe")
with path.open(encoding="utf-8") as stream:
data = yaml.load(stream) or {}
if not isinstance(data, dict):
raise ValueError("configuration root must be a YAML mapping")
root = Path(str(data.get("root_dir", default.root_dir)))
service_user = str(data.get("service_user", default.service_user)).strip()
required_group = str(data.get("required_group", default.required_group)).strip()
platform_groups = [str(x).strip() for x in data.get("platform_groups", default.platform_groups) if str(x).strip()]
if not service_user:
raise ValueError("service_user must not be empty")
if not required_group:
raise ValueError("required_group must not be empty")
if not platform_groups:
raise ValueError("platform_groups must contain at least one group")
ui = data.get("ui", {}) or {}
if not isinstance(ui, dict):
raise ValueError("ui must be a mapping")
output = str(ui.get("output", "compact"))
if output not in {"compact", "live"}:
raise ValueError("ui.output must be compact or live")
for setting in ("clear_screen", "ascii"):
if setting in ui and not isinstance(ui[setting], bool):
raise ValueError(f"ui.{setting} must be true or false")
return cls(root, service_user, required_group, platform_groups,
ui.get("clear_screen", True), ui.get("ascii", False), output)
except Exception as exc:
raise ConfigurationError(f"Could not load {path}: {exc}") from exc
def save(self) -> None:
"""Merge known settings; preserve comments and unknown customer additions."""
self.root_dir.mkdir(parents=True, exist_ok=True)
yaml = YAML()
yaml.preserve_quotes = True
original = self.config_path.read_bytes() if self.config_path.exists() else None
data = yaml.load(original.decode("utf-8")) if original else {}
if data is None:
data = {}
if not isinstance(data, dict):
raise ConfigurationError("Existing configuration root is not a mapping")
data.update(root_dir=str(self.root_dir), service_user=self.service_user,
required_group=self.required_group, platform_groups=self.platform_groups)
ui = data.setdefault("ui", {})
if not isinstance(ui, dict):
raise ConfigurationError("Existing ui configuration is not a mapping")
ui.update(clear_screen=self.ui_clear_screen, ascii=self.ui_ascii, output=self.ui_output)
fd, name = tempfile.mkstemp(prefix=".aim-config-", dir=self.root_dir)
tmp = Path(name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
yaml.dump(data, stream)
stream.flush()
os.fsync(stream.fileno())
current = self.config_path.read_bytes() if self.config_path.exists() else None
if current != original:
raise ConfigurationError("Configuration changed externally; reload before saving")
apply_managed_permissions(tmp, self.required_group)
os.replace(tmp, self.config_path)
except OSError as exc:
raise ConfigurationError(f"Could not save {self.config_path}: {exc}") from exc
finally:
tmp.unlink(missing_ok=True)
+318
View File
@@ -0,0 +1,318 @@
from __future__ import annotations
import os
import re
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from ruamel.yaml import YAML
from aim.exceptions import CustomerExists, CustomerNotFound, ValidationFailed
from aim.inventory.validator import validate
from aim.locking import InventoryLock
from aim.permissions import apply_managed_permissions
from aim.templates import group_var_templates
CUSTOMER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
@dataclass
class CustomerDefaults:
domain_suffix: str = ""
network_address: str = ""
netmask: str = ""
ad_dns_domain: str = ""
ad_netbios_domain: str = ""
class CustomerManager:
def __init__(self, config):
self.config = config
def path(self, name: str) -> Path:
return self.config.inventory_dir / name
def list(self) -> list[str]:
root = self.config.inventory_dir
if not root.exists():
return []
return sorted(p.name for p in root.iterdir() if p.is_dir() and not p.name.startswith(".aim-"))
def _validate_name(self, name: str) -> None:
if not CUSTOMER_RE.fullmatch(name):
raise ValueError("Invalid customer name. Use letters, numbers, dots, underscores, or hyphens.")
def _write(self, path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
apply_managed_permissions(path, self.config.required_group)
def defaults_path(self, customer: str) -> Path:
return self.require(customer) / ".aim.yml"
def load_defaults(self, customer: str) -> CustomerDefaults:
path = self.defaults_path(customer)
if not path.exists():
return CustomerDefaults()
yaml = YAML(typ="safe")
with path.open(encoding="utf-8") as stream:
data = yaml.load(stream) or {}
if not isinstance(data, dict):
raise ValueError(f"Customer defaults must be a YAML mapping: {path}")
return CustomerDefaults(
domain_suffix=str(data.get("domain_suffix", "") or "").strip().lstrip("."),
network_address=str(data.get("network_address", "") or "").strip(),
netmask=str(data.get("netmask", "") or "").strip(),
ad_dns_domain=str(data.get("ad_dns_domain", "") or "").strip().lstrip("."),
ad_netbios_domain=str(data.get("ad_netbios_domain", "") or "").strip(),
)
def save_defaults(self, customer: str, defaults: CustomerDefaults) -> Path:
path = self.defaults_path(customer)
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
original = path.read_bytes() if path.exists() else None
yaml.preserve_quotes = True
data = yaml.load(original.decode("utf-8")) if original else {}
if data is None:
data = {}
if not isinstance(data, dict):
raise ValidationFailed(f"Customer defaults must be a YAML mapping: {path}")
data.update({
"domain_suffix": defaults.domain_suffix.strip().lstrip("."),
"network_address": defaults.network_address.strip(),
"netmask": defaults.netmask.strip(),
"ad_dns_domain": defaults.ad_dns_domain.strip().lstrip("."),
"ad_netbios_domain": defaults.ad_netbios_domain.strip(),
})
fd, filename = tempfile.mkstemp(prefix=".aim-defaults-", dir=path.parent)
tmp = Path(filename)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
yaml.dump(data, stream)
stream.flush()
os.fsync(stream.fileno())
current = path.read_bytes() if path.exists() else None
if current != original:
raise ValidationFailed("Customer defaults changed externally; reload before saving")
apply_managed_permissions(tmp, self.config.required_group)
os.replace(tmp, path)
finally:
tmp.unlink(missing_ok=True)
return path
def create_structure(self, name: str) -> Path:
self._validate_name(name)
final = self.path(name)
if final.exists():
raise CustomerExists(f"Customer already exists: {name}")
self.config.inventory_dir.mkdir(parents=True, exist_ok=True)
try:
apply_managed_permissions(self.config.inventory_dir, self.config.required_group, directory=True)
except OSError:
pass
temp = Path(tempfile.mkdtemp(prefix=f".aim-{name}-", dir=self.config.inventory_dir))
try:
apply_managed_permissions(temp, self.config.required_group, directory=True)
directories = ["group_vars/all", "host_vars"]
directories.extend(f"group_vars/{group}" for group in self.config.platform_groups)
if "linux" in self.config.platform_groups:
directories.append("group_vars/linux/.ssh")
for rel in directories:
directory = temp / rel
directory.mkdir(parents=True, exist_ok=True)
apply_managed_permissions(directory, self.config.required_group, directory=True)
defaults: dict[str, str] = {
"group_vars/all/main.yml": f"# group_vars/all/main.yml for {name}\n",
}
for rel, (header, data) in group_var_templates(str(final), self.config.service_user).items():
from io import StringIO
yaml = YAML()
yaml.default_flow_style = False
stream = StringIO()
stream.write(header)
# Keep the Sophos password hint as a hand-editable comment.
if rel == "group_vars/sophosxgs/main.yml":
stream.write('#ansible_password: "{{ ansible_password }}" # Passwort wird aus --ask-pass übernommen\n')
yaml.dump(dict(data), stream)
defaults[rel] = stream.getvalue()
for rel, content in defaults.items():
if (temp / rel).parent.exists():
self._write(temp / rel, content)
hosts = temp / "hosts.yml"
self._write(hosts, f"all:\n children:\n {name}:\n children: {{}}\n")
validate(hosts, customer=name)
os.rename(temp, final)
return final
except Exception:
shutil.rmtree(temp, ignore_errors=True)
raise
def set_windows_ansible_user(self, customer: str, username: str) -> Path:
"""Set the Windows ansible_user explicitly while preserving other group vars."""
username = username.strip()
if not username:
raise ValueError("Windows ansible_user must not be empty")
path = self.require(customer) / "group_vars" / "windows" / "main.yml"
path.parent.mkdir(parents=True, exist_ok=True)
apply_managed_permissions(path.parent, self.config.required_group, directory=True)
yaml = YAML()
yaml.preserve_quotes = True
data = {}
if path.exists():
try:
with path.open("r", encoding="utf-8") as stream:
data = yaml.load(stream) or {}
except Exception as exc:
raise ValidationFailed(f"Invalid YAML in {path}: {exc}") from exc
if not isinstance(data, dict):
raise ValidationFailed(f"Windows group vars must be a YAML mapping: {path}")
if data.get("ansible_user") != username:
data["ansible_user"] = username
with path.open("w", encoding="utf-8") as stream:
yaml.dump(data, stream)
apply_managed_permissions(path, self.config.required_group)
return path
def ensure_windows_service_user(self, customer: str) -> Path:
"""Set the standard Windows service user when ansible_user is missing/blank.
Existing non-empty custom usernames are preserved.
"""
path = self.require(customer) / "group_vars" / "windows" / "main.yml"
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
apply_managed_permissions(path.parent, self.config.required_group, directory=True)
self._write(path, f"ansible_user: {self.config.service_user}\n")
return path
yaml = YAML()
yaml.preserve_quotes = True
try:
with path.open("r", encoding="utf-8") as stream:
data = yaml.load(stream) or {}
except Exception as exc:
raise ValidationFailed(f"Invalid YAML in {path}: {exc}") from exc
if not isinstance(data, dict):
raise ValidationFailed(f"Windows group vars must be a YAML mapping: {path}")
current = data.get("ansible_user")
if current is None or (isinstance(current, str) and not current.strip()):
data["ansible_user"] = self.config.service_user
with path.open("w", encoding="utf-8") as stream:
yaml.dump(data, stream)
apply_managed_permissions(path, self.config.required_group)
return path
def set_windows_host_credential_model(self, customer: str, fqdn: str, model: str) -> Path:
"""Write the selected Windows credential override into host_vars."""
path = self.ensure_host_vars(customer, fqdn)
yaml = YAML()
yaml.preserve_quotes = True
data = {}
if path.stat().st_size:
with path.open("r", encoding="utf-8") as stream:
data = yaml.load(stream) or {}
if not isinstance(data, dict):
raise ValueError(f"Host vars must be a YAML mapping: {path}")
if model == "domain":
data.pop("ansible_user", None)
data.pop("ansible_password", None)
elif model == "shared_local":
data["ansible_user"] = self.config.service_user
data["ansible_password"] = "{{ vault_windows_local_ansible_password }}"
elif model == "host_local":
key = re.sub(r"[^a-z0-9]+", "_", fqdn.lower()).strip("_")
data["ansible_user"] = self.config.service_user
data["ansible_password"] = "{{ vault_ansible_password_" + key + " }}"
else:
raise ValueError("Unknown Windows credential model")
with path.open("w", encoding="utf-8") as stream:
yaml.dump(data, stream)
apply_managed_permissions(path, self.config.required_group)
return path
def ensure_host_vars(self, customer: str, fqdn: str) -> Path:
if not CUSTOMER_RE.fullmatch(fqdn):
# FQDN characters are intentionally restricted to safe path components.
raise ValueError("Invalid host name for host_vars path")
directory = self.require(customer) / "host_vars" / fqdn
directory.mkdir(parents=True, exist_ok=True)
apply_managed_permissions(directory, self.config.required_group, directory=True)
path = directory / "main.yml"
if not path.exists():
self._write(path, "")
return path
def ensure_sophos_host_vars(self, customer: str, fqdn: str) -> Path:
"""Create a commented Sophos XGS starter template for a new/empty host_vars file.
The template is intentionally comment-only. Operators can use it by hand,
while AIM's Sophos configuration flow appends active YAML below it.
Existing non-empty host_vars are preserved verbatim.
"""
path = self.ensure_host_vars(customer, fqdn)
if path.exists() and path.stat().st_size > 0:
return path
hostname = fqdn.split(".", 1)[0]
content = f"""# host_vars for {hostname}
#####################################
# hostname: {hostname}
# network_objects:
# example1:
# name: "Example 1" # NATIVE VLAN
# network: "0.0.0.0" # NETWORK ADDRESS
# subnetmask: "255.255.255.0"
#
# example2:
# name: "Example 2" # OTHER VLAN
# network: "0.0.0.0" # NETWORK ADDRESS
# subnetmask: "255.255.255.0"
#
# add other network objects here
# #####################################
#
# vlan_interfaces:
# example2:
# name: "Example 2"
# ip_address: "0.0.0.0" # INTERFACE IP NOT NETWORK ADDRESS
# subnetmask: "255.255.255.0"
# vlan_id: 101
# zone_name: "Example 2"
# zone_type: "LAN"
# zone_description: "Example 2 Zone (LAN)"
#
# add other vlan interfaces here
# #####################################
"""
self._write(path, content)
return path
def remove_host_vars(self, customer: str, fqdn: str) -> bool:
directory = self.require(customer) / "host_vars" / fqdn
if directory.is_dir():
shutil.rmtree(directory)
return True
return False
def delete(self, name: str) -> None:
path = self.require(name)
inventory = path / "hosts.yml"
with InventoryLock(inventory):
shutil.rmtree(path)
def require(self, name: str) -> Path:
path = self.path(name)
if not path.is_dir():
raise CustomerNotFound(f"Customer not found: {name}")
return path
+46
View File
@@ -0,0 +1,46 @@
class AIMError(Exception):
"""Base class for expected AIM errors."""
class CustomerExists(AIMError):
pass
class CustomerNotFound(AIMError):
pass
class HostNotFound(AIMError):
pass
class InvalidInventory(AIMError):
pass
class InventoryConflict(AIMError):
pass
class InventoryLocked(AIMError):
pass
class ExternalCommandFailed(AIMError):
pass
class ValidationFailed(AIMError):
pass
class ValidationIncomplete(AIMError):
pass
class ConfigurationError(AIMError):
pass
class OperationCancelled(AIMError):
"""User cancelled the current operation; return to its owning menu."""
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
import os
import shutil
import signal
import subprocess
from pathlib import Path
from typing import Sequence
from aim.exceptions import ExternalCommandFailed, OperationCancelled
_presenter = ContextVar('aim_external_presenter', default=None)
@contextmanager
def external_presentation(presenter):
"""Install presentation per application context, not in business managers."""
token = _presenter.set(presenter)
try:
yield
finally:
_presenter.reset(token)
def stable_cwd(preferred: Path | None = None) -> Path:
candidates = [preferred, Path('/etc/ansible'), Path.home(), Path('/')]
for candidate in candidates:
if candidate is None:
continue
try:
if candidate.is_dir():
return candidate
except OSError:
pass
return Path('/')
def command_available(command: str) -> bool:
return shutil.which(command) is not None
def stop_process(process: subprocess.Popen, *, group: bool = False) -> None:
"""Reap a cancelled command; do not claim to undo work on remote systems."""
if process.poll() is not None:
return
try:
if group and os.name == 'posix':
os.killpg(process.pid, signal.SIGTERM)
else:
process.terminate()
process.wait(timeout=3)
except subprocess.TimeoutExpired:
if group and os.name == 'posix':
os.killpg(process.pid, signal.SIGKILL)
else:
process.kill()
process.wait()
except ProcessLookupError:
process.wait()
def execute(args, kwargs):
"""Default terminal behavior, including native password/editor interaction."""
process = subprocess.Popen(list(args), **kwargs)
try:
stdout, stderr = process.communicate()
return subprocess.CompletedProcess(list(args), process.returncode, stdout, stderr)
finally:
# Runs on KeyboardInterrupt, SIGTERM/SystemExit, or a regular exception.
stop_process(process)
def run_external(args: Sequence[str], *, cwd: Path | None = None,
capture_output: bool = False, suppress_stdout: bool = False,
capture_stdout: bool = False, capture_stderr: bool = False,
text: bool = True, env: dict[str, str] | None = None,
ui_mode: str = 'auto') -> subprocess.CompletedProcess:
if (capture_output or capture_stdout) and suppress_stdout:
raise ValueError('capture_output and suppress_stdout are mutually exclusive')
if not args:
raise ValueError('An external command is required')
kwargs = {'cwd': stable_cwd(cwd), 'text': text}
if env is not None:
kwargs['env'] = env
if capture_output:
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif capture_stdout:
kwargs['stdout'] = subprocess.PIPE
elif suppress_stdout:
kwargs['stdout'] = subprocess.DEVNULL
if capture_stderr:
kwargs['stderr'] = subprocess.PIPE
try:
presenter = _presenter.get()
if presenter:
return presenter(list(args), kwargs, capture_output=capture_output or capture_stdout or capture_stderr,
suppress_stdout=suppress_stdout, ui_mode=ui_mode)
return execute(args, kwargs)
except KeyboardInterrupt:
raise OperationCancelled('External command interrupted. Completed remote work is not rolled back.') from None
except FileNotFoundError as exc:
raise ExternalCommandFailed(f'Required command not found: {args[0]}') from exc
except OSError as exc:
raise ExternalCommandFailed(f'Could not execute {args[0]}: {exc}') from exc
+219
View File
@@ -0,0 +1,219 @@
from __future__ import annotations
from collections import defaultdict
from ruamel.yaml.comments import CommentedMap
from aim.exceptions import HostNotFound, InventoryConflict
from aim.inventory.model import ChangeSet, Host
class InventoryEditor:
def __init__(self, document, customer: str):
self.doc = document
self.customer = customer
def _customer_groups(self):
all_node = self.doc.data.setdefault("all", CommentedMap())
if not isinstance(all_node, dict):
raise InventoryConflict("'all' must be a YAML mapping")
children = all_node.setdefault("children", CommentedMap())
if not isinstance(children, dict):
raise InventoryConflict("all.children must be a YAML mapping")
customer = children.setdefault(self.customer, CommentedMap())
if not isinstance(customer, dict):
raise InventoryConflict(f"Customer node {self.customer!r} must be a YAML mapping")
groups = customer.setdefault("children", CommentedMap())
if not isinstance(groups, dict):
raise InventoryConflict(f"Customer {self.customer!r} children must be a YAML mapping")
return groups
def _customer_node(self):
all_node = self.doc.data.get("all")
if not isinstance(all_node, dict):
return None
children = all_node.get("children")
if not isinstance(children, dict):
return None
node = children.get(self.customer)
return node if isinstance(node, dict) else None
def _walk_host_occurrences(self, node=None, path=()):
node = self.doc.data if node is None else node
if not isinstance(node, dict):
return
hosts = node.get("hosts")
if isinstance(hosts, dict):
for fqdn, vars_ in list(hosts.items()):
yield str(fqdn), vars_, path, hosts
children = node.get("children")
if isinstance(children, dict):
for name, child in list(children.items()):
yield from self._walk_host_occurrences(child, path + (str(name),))
def _walk_customer_occurrences(self):
node = self._customer_node()
if node is not None:
yield from self._walk_host_occurrences(node, ())
def find_customer_host(self, fqdn: str):
return [item for item in self._walk_customer_occurrences() if item[0] == fqdn]
def list_hosts(self) -> list[Host]:
grouped: dict[str, list] = defaultdict(list)
for occurrence in self._walk_customer_occurrences():
grouped[occurrence[0]].append(occurrence)
result: list[Host] = []
for fqdn in sorted(grouped):
occurrences = grouped[fqdn]
ips = {
str(vars_.get("ansible_host"))
for _, vars_, _, _ in occurrences
if isinstance(vars_, dict) and vars_.get("ansible_host") is not None
}
ip = next(iter(ips)) if len(ips) == 1 else ("CONFLICT" if ips else "")
host = Host(fqdn=fqdn, ansible_host=ip)
for _, _, path, _ in occurrences:
if not path:
continue
group = path[0]
if len(path) >= 2:
host.add_subgroup(group, "/".join(path[1:]))
else:
host.add_group(group)
result.append(host)
return result
@staticmethod
def _desired_labels(desired: Host) -> set[tuple[str, str | None]]:
labels: set[tuple[str, str | None]] = set()
for group, membership in desired.memberships.items():
if membership.subgroups:
labels.update((group, subgroup) for subgroup in membership.subgroups)
else:
labels.add((group, None))
return labels
@staticmethod
def _prune_empty_managed_node(node: dict) -> None:
"""Remove only empty structural keys; preserve vars/custom keys/comments."""
hosts = node.get("hosts")
if isinstance(hosts, dict) and not hosts:
del node["hosts"]
children = node.get("children")
if isinstance(children, dict):
for key in list(children):
child = children[key]
if isinstance(child, dict):
InventoryEditor._prune_empty_managed_node(child)
if not children:
del node["children"]
def _prune_customer(self) -> None:
node = self._customer_node()
if isinstance(node, dict):
self._prune_empty_managed_node(node)
# Keep an empty children mapping for AIM's canonical customer shape.
node.setdefault("children", CommentedMap())
def upsert_host(self, desired: Host) -> ChangeSet:
if not desired.fqdn or not desired.ansible_host:
raise ValueError("Host FQDN and IP are required")
if not desired.memberships:
raise ValueError("At least one group membership is required")
changes = ChangeSet()
occurrences = self.find_customer_host(desired.fqdn)
ips = {
str(vars_.get("ansible_host"))
for _, vars_, _, _ in occurrences
if isinstance(vars_, dict) and vars_.get("ansible_host") is not None
}
if len(ips) > 1:
raise InventoryConflict(
f"{desired.fqdn} has conflicting ansible_host values in customer {self.customer}: {sorted(ips)}"
)
if ips and desired.ansible_host not in ips:
changes.changed.append(f"ansible_host: {next(iter(ips))} -> {desired.ansible_host}")
desired_labels = self._desired_labels(desired)
# Reconcile canonical AIM memberships. Deeper or direct-customer host
# occurrences are valid arbitrary Ansible structures and are preserved.
for _, vars_, path, hosts in occurrences:
if not path:
changes.warnings.append(
f"{desired.fqdn} also exists directly below the customer node; AIM preserved that occurrence"
)
if isinstance(vars_, dict) and vars_.get("ansible_host") != desired.ansible_host:
vars_["ansible_host"] = desired.ansible_host
continue
if len(path) > 2:
changes.warnings.append(
f"{desired.fqdn} also exists in custom nested path {'/'.join(path)}; AIM preserved that membership"
)
if isinstance(vars_, dict) and vars_.get("ansible_host") != desired.ansible_host:
vars_["ansible_host"] = desired.ansible_host
continue
label = (path[0], path[1] if len(path) == 2 else None)
if label not in desired_labels:
del hosts[desired.fqdn]
changes.removed.append("/".join(item for item in label if item))
elif isinstance(vars_, dict) and vars_.get("ansible_host") != desired.ansible_host:
vars_["ansible_host"] = desired.ansible_host
groups = self._customer_groups()
for group, membership in desired.memberships.items():
group_node = groups.setdefault(group, CommentedMap())
if not isinstance(group_node, dict):
raise InventoryConflict(f"Group {group!r} is not a YAML mapping and cannot be managed safely")
if membership.subgroups:
child_nodes = group_node.setdefault("children", CommentedMap())
if not isinstance(child_nodes, dict):
raise InventoryConflict(f"Group {group!r} has a non-mapping children value")
for subgroup in sorted(membership.subgroups):
if "/" in subgroup:
raise ValueError("Subgroup names entered in AIM must be one level deep")
subgroup_node = child_nodes.setdefault(subgroup, CommentedMap())
if not isinstance(subgroup_node, dict):
raise InventoryConflict(f"Subgroup {group}/{subgroup} is not a YAML mapping")
hosts = subgroup_node.setdefault("hosts", CommentedMap())
if not isinstance(hosts, dict):
raise InventoryConflict(f"Subgroup {group}/{subgroup} has a non-mapping hosts value")
current = hosts.get(desired.fqdn)
if current is None:
hosts[desired.fqdn] = CommentedMap(ansible_host=desired.ansible_host)
changes.added.append(f"{group}/{subgroup}")
elif not isinstance(current, dict):
raise InventoryConflict(f"Host {desired.fqdn} in {group}/{subgroup} is not a variable mapping")
elif current.get("ansible_host") != desired.ansible_host:
current["ansible_host"] = desired.ansible_host
else:
hosts = group_node.setdefault("hosts", CommentedMap())
if not isinstance(hosts, dict):
raise InventoryConflict(f"Group {group!r} has a non-mapping hosts value")
current = hosts.get(desired.fqdn)
if current is None:
hosts[desired.fqdn] = CommentedMap(ansible_host=desired.ansible_host)
changes.added.append(group)
elif not isinstance(current, dict):
raise InventoryConflict(f"Host {desired.fqdn} in {group} is not a variable mapping")
elif current.get("ansible_host") != desired.ansible_host:
current["ansible_host"] = desired.ansible_host
self._prune_customer()
return changes
def remove_host(self, fqdn: str) -> ChangeSet:
occurrences = self.find_customer_host(fqdn)
if not occurrences:
raise HostNotFound(fqdn)
changes = ChangeSet()
for _, _, path, hosts in occurrences:
del hosts[fqdn]
changes.removed.append("/".join(path) or "customer/root")
self._prune_customer()
return changes
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from pathlib import Path
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
from aim.exceptions import InvalidInventory
class InventoryDocument:
def __init__(self, path: Path):
self.path = path
self.yaml = YAML()
self.yaml.preserve_quotes = True
self.yaml.indent(mapping=2, sequence=4, offset=2)
try:
self.original_bytes = path.read_bytes()
with path.open(encoding="utf-8") as stream:
self.data = self.yaml.load(stream)
except Exception as exc:
raise InvalidInventory(f"Cannot parse {path}: {exc}") from exc
if self.data is None:
self.data = CommentedMap()
if not isinstance(self.data, dict):
raise InvalidInventory("Inventory root must be a YAML mapping")
def custom_structure_notes(self, customer: str) -> list[str]:
notes: list[str] = []
root_extra = [str(key) for key in self.data if key != "all"]
if root_extra:
notes.append("Additional top-level inventory keys are present: " + ", ".join(root_extra))
all_node = self.data.get("all")
if not isinstance(all_node, dict):
notes.append("The 'all' inventory node is missing or not a mapping.")
return notes
all_children = all_node.get("children")
if not isinstance(all_children, dict):
notes.append("all.children is missing or not a mapping.")
return notes
customer_node = all_children.get(customer)
if not isinstance(customer_node, dict):
notes.append(f"Customer node {customer!r} is missing or not a mapping.")
return notes
custom_customer_keys = [str(key) for key in customer_node if key not in {"children", "hosts", "vars"}]
if custom_customer_keys:
notes.append("Customer inventory contains custom keys: " + ", ".join(custom_customer_keys))
if customer_node.get("hosts"):
notes.append("Customer inventory contains hosts directly below the customer node.")
if customer_node.get("vars"):
notes.append("Customer inventory contains customer-level vars.")
groups = customer_node.get("children")
if isinstance(groups, dict):
for group, group_node in groups.items():
if not isinstance(group_node, dict):
notes.append(f"Group {group!r} is not a mapping and will not be modified safely.")
continue
children = group_node.get("children")
if isinstance(children, dict):
for subgroup, subgroup_node in children.items():
if isinstance(subgroup_node, dict) and isinstance(subgroup_node.get("children"), dict):
notes.append(f"Custom nested structure exists below {group}/{subgroup}; AIM will preserve it.")
return notes
+43
View File
@@ -0,0 +1,43 @@
from dataclasses import dataclass, field
@dataclass
class GroupMembership:
group: str
subgroups: set[str] = field(default_factory=set)
@dataclass
class Host:
fqdn: str
ansible_host: str
memberships: dict[str, GroupMembership] = field(default_factory=dict)
def add_group(self, group: str) -> None:
self.memberships.setdefault(group, GroupMembership(group))
def add_subgroup(self, group: str, subgroup: str) -> None:
self.add_group(group)
self.memberships[group].subgroups.add(subgroup)
def membership_labels(self) -> list[str]:
labels: list[str] = []
for group in sorted(self.memberships):
subs = self.memberships[group].subgroups
if subs:
labels.extend(f"{group}/{sub}" for sub in sorted(subs))
else:
labels.append(group)
return labels
@dataclass
class ChangeSet:
added: list[str] = field(default_factory=list)
changed: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def changed_anything(self) -> bool:
return bool(self.added or self.changed or self.removed)
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from ruamel.yaml import YAML
from aim.exceptions import ValidationFailed
from aim.external import command_available, run_external
@dataclass(frozen=True)
class ValidationResult:
yaml_ok: bool = True
ansible_ok: bool = False
vault_used: bool = False
def _vault_path_for_inventory(path: Path) -> Path:
return path.parent / "group_vars" / "all" / "vault.yml"
def validate_yaml(path: Path) -> ValidationResult:
"""Validate YAML locally without invoking Ansible or Vault prompts."""
try:
with path.open(encoding="utf-8") as stream:
data = YAML(typ="safe").load(stream)
if data is not None and not isinstance(data, dict):
raise ValueError("inventory root must be a YAML mapping")
except Exception as exc:
raise ValidationFailed(f"YAML validation failed: {exc}") from exc
return ValidationResult(yaml_ok=True, ansible_ok=False, vault_used=False)
def validate(path: Path, *, customer: str | None = None) -> ValidationResult:
"""Run full YAML + ansible-inventory validation, including Vault when present."""
validate_yaml(path)
if not command_available("ansible-inventory"):
raise ValidationFailed("Required command not found: ansible-inventory")
customer = customer or path.parent.name
args = ["ansible-inventory", "-i", str(path), "--list"]
vault = _vault_path_for_inventory(path)
vault_used = vault.is_file()
if vault_used:
args.extend(["--vault-id", f"{customer}@prompt"])
result = run_external(args, cwd=path.parent, suppress_stdout=True, capture_stderr=True)
if result.returncode:
import re
detail = result.stderr or ""
location = re.search(r"Origin:\s*([^\r\n]+)", detail)
hint = f" Location: {location.group(1)}" if location else ""
raise ValidationFailed("ansible-inventory validation failed." + hint +
" Check YAML syntax, variable references and Vault access. Raw diagnostics were not displayed because they can contain secrets.")
return ValidationResult(yaml_ok=True, ansible_ok=True, vault_used=vault_used)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import os
import stat
import tempfile
from pathlib import Path
from aim.exceptions import InventoryConflict
from aim.inventory.validator import validate_yaml
from aim.locking import InventoryLock
class InventoryWriter:
def __init__(self, backups):
self.backups = backups
def commit(self, document, *, customer: str | None = None) -> bool:
path = document.path
path.parent.mkdir(parents=True, exist_ok=True)
customer = customer or path.parent.name
with InventoryLock(path):
if path.exists() and path.read_bytes() != document.original_bytes:
raise InventoryConflict(
f"{path.name} changed after AIM loaded it. Reload and repeat the operation; nothing was overwritten."
)
fd, temp_name = tempfile.mkstemp(prefix=".aim-", suffix=".yml", dir=path.parent)
os.close(fd)
temp = Path(temp_name)
try:
with temp.open("w", encoding="utf-8") as stream:
document.yaml.dump(document.data, stream)
if path.exists():
temp.chmod(stat.S_IMODE(path.stat().st_mode))
validate_yaml(temp)
candidate = temp.read_bytes()
if path.exists() and path.read_bytes() == candidate:
return False
if path.exists() and path.read_bytes() != document.original_bytes:
raise InventoryConflict(
f"{path.name} changed while AIM was validating the candidate. Nothing was overwritten."
)
self.backups.backup_once(path)
os.replace(temp, path)
document.original_bytes = path.read_bytes()
return True
finally:
temp.unlink(missing_ok=True)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import fcntl
import os
from pathlib import Path
from aim.exceptions import InventoryLocked
class InventoryLock:
"""Non-blocking advisory lock for one customer inventory.
The lock file is removed after the protected operation when no other AIM
process has acquired it. A process that acquires the lock in the meantime
owns cleanup of the file when its operation completes.
"""
def __init__(self, inventory: Path):
self.path = inventory.parent / ".aim.lock"
self._stream = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self._stream = self.path.open("a+", encoding="utf-8")
try:
fcntl.flock(self._stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
self._stream.close()
self._stream = None
raise InventoryLocked(
f"Inventory is currently being modified by another AIM process: {self.path.parent.name}"
) from exc
return self
def __exit__(self, exc_type, exc, tb):
if self._stream is not None:
fcntl.flock(self._stream.fileno(), fcntl.LOCK_UN)
self._stream.close()
self._stream = None
self._cleanup_unused_lock_file()
def _cleanup_unused_lock_file(self) -> None:
"""Remove an idle lock file without interfering with another AIM process."""
try:
stream = self.path.open("a+", encoding="utf-8")
except FileNotFoundError:
return
try:
try:
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
try:
fd_stat = os.fstat(stream.fileno())
path_stat = self.path.stat()
except FileNotFoundError:
return
if (fd_stat.st_dev, fd_stat.st_ino) == (path_stat.st_dev, path_stat.st_ino):
self.path.unlink(missing_ok=True)
finally:
try:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
except OSError:
pass
stream.close()
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import grp
import os
from pathlib import Path
from aim.exceptions import ConfigurationError
def configured_gid(group_name: str) -> int:
try:
return grp.getgrnam(group_name).gr_gid
except KeyError as exc:
raise ConfigurationError(f"Configured group cannot be resolved: {group_name}") from exc
def apply_managed_permissions(path: Path, group_name: str, *, directory: bool = False, secret: bool = False) -> None:
"""Apply AIM's group ownership when permitted by the OS.
Files are group-readable/writable unless secret=True. Directories are setgid
so newly created content keeps the configured admin group.
"""
gid = configured_gid(group_name)
try:
os.chown(path, -1, gid)
except PermissionError:
# A member of the group may be able to create content but not chown on
# every filesystem/NSS setup. The caller's inherited group remains valid.
pass
if directory:
path.chmod(0o2770)
elif secret:
path.chmod(0o660)
else:
path.chmod(0o660)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from aim.exceptions import ExternalCommandFailed
from aim.external import command_available, run_external
@dataclass(frozen=True)
class PlaybookSpec:
key: str
name: str
filename: str
category: str
platforms: tuple[str, ...]
extra_vars: tuple[str, ...] = ()
customer_specific: bool = False
ask_pass: bool = False
require_vault: bool = False
PLAYBOOKS: tuple[PlaybookSpec, ...] = (
PlaybookSpec("checkmk_cleanup", "Cleanup CheckMK", "checkmk_cleanup.yml", "CheckMK", ("linux", "windows"), ("checkmk_cleanup_enabled=true",)),
PlaybookSpec("checkmk_install_agent", "Install CheckMK Agent", "checkmk_install_agent.yml", "CheckMK", ("linux", "windows")),
PlaybookSpec("checkmk_update_config", "Update CheckMK Config", "checkmk_update_config.yml", "CheckMK", ("linux", "windows")),
PlaybookSpec("debug_ping", "Ping", "debug_ping.yml", "Debug", ("linux", "windows")),
PlaybookSpec("debug_server_role_selection", "Server Role Selection", "debug_server_role_selection.yml", "Debug", ("linux", "windows")),
PlaybookSpec("debug_disk_usage", "Disk Usage", "debug_disk_usage.yml", "Debug", ("windows",)),
PlaybookSpec("patch_os", "Patch OS", "patch_os.yml", "Maintenance", ("linux", "windows")),
PlaybookSpec("reboot_system", "Reboot System", "reboot_system.yml", "Maintenance", ("linux", "windows")),
PlaybookSpec("backup_eventlog", "Backup Event Log", "backup_eventlog.yml", "Maintenance", ("windows",)),
PlaybookSpec("start_stopped_services", "Start Stopped Services", "start_stopped_services.yml", "Maintenance", ("windows",)),
PlaybookSpec("configure_sophos_initial", "Initial Bitformer Config", "configure_sophos_initial_bitformer_config.yml", "Sophos XGS", ("sophosxgs",), ask_pass=True, require_vault=True),
PlaybookSpec("configure_sophosxgs", "Configure Sophos XGS", "configure_sophosxgs.yml", "Sophos XGS", ("sophosxgs",), customer_specific=True, ask_pass=True, require_vault=True),
)
CATEGORY_ORDER = ("CheckMK", "Debug", "Maintenance", "Sophos XGS")
class PlaybookManager:
def __init__(self, customers, config):
self.customers = customers
self.config = config
@property
def root(self) -> Path:
return self.config.root_dir / "playbooks"
def categories(self) -> list[str]:
return list(CATEGORY_ORDER)
def specs_for_category(self, category: str) -> list[PlaybookSpec]:
return [spec for spec in PLAYBOOKS if spec.category == category]
def path(self, spec: PlaybookSpec, customer: str | None = None) -> Path:
if spec.customer_specific:
if not customer:
raise ValueError(f"Customer is required for playbook {spec.name}")
return self.root / "customers" / customer / spec.filename
return self.root / spec.filename
def available(self, spec: PlaybookSpec, customer: str | None = None) -> bool:
return self.path(spec, customer).is_file()
def run(self, customer: str, inventory: Path, spec: PlaybookSpec, *, limit: str | None = None) -> None:
playbook = self.path(spec, customer)
if not playbook.is_file():
raise FileNotFoundError(playbook)
if not inventory.is_file():
raise FileNotFoundError(inventory)
if not command_available("ansible-playbook"):
raise ExternalCommandFailed("Required command not found: ansible-playbook")
args = ["ansible-playbook", "-i", str(inventory), str(playbook)]
vault = inventory.parent / "group_vars" / "all" / "vault.yml"
if spec.require_vault and not vault.is_file():
raise FileNotFoundError(f"Required customer vault not found: {vault}")
if vault.is_file():
args.extend(["--vault-id", f"{customer}@prompt"])
if spec.ask_pass:
args.append("--ask-pass")
if limit:
args.extend(["--limit", limit])
for extra_var in spec.extra_vars:
args.extend(["-e", extra_var])
result = run_external(args, cwd=self.config.root_dir)
if result.returncode:
raise ExternalCommandFailed(f"Playbook failed with exit code {result.returncode}")
+1
View File
@@ -0,0 +1 @@
"""Sophos XGS host configuration helpers."""
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import os
import stat
import tempfile
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
from aim.permissions import apply_managed_permissions
class SophosConfigManager:
"""Merge AIM-managed Sophos variables into a host_vars YAML file.
Unknown/manual YAML and comments are preserved. AIM only creates/updates the
hostname, network_objects and vlan_interfaces entries explicitly supplied by
the operator.
"""
def __init__(self, customers):
self.customers = customers
def path(self, customer: str, fqdn: str) -> Path:
return self.customers.ensure_host_vars(customer, fqdn)
@staticmethod
def default_network_object_name(customer: str, key: str) -> str:
"""Build the conventional Sophos object name: net_kunde_objectkey."""
def normalize(value: str) -> str:
return "_".join(part for part in __import__("re").split(r"[^A-Za-z0-9]+", value) if part).lower()
customer_part = normalize(customer)
key_part = normalize(key)
return f"net_{customer_part}_{key_part}"
@staticmethod
def _yaml() -> YAML:
yaml = YAML()
yaml.preserve_quotes = True
yaml.indent(mapping=2, sequence=4, offset=2)
return yaml
def load(self, customer: str, fqdn: str) -> CommentedMap:
path = self.path(customer, fqdn)
yaml = self._yaml()
if not path.exists() or path.stat().st_size == 0:
return CommentedMap()
with path.open("r", encoding="utf-8") as stream:
data = yaml.load(stream)
if data is None:
return CommentedMap()
if not isinstance(data, dict):
raise ValueError(f"{path} must contain a YAML mapping")
return data
def existing_keys(self, customer: str, fqdn: str) -> tuple[set[str], set[str]]:
data = self.load(customer, fqdn)
network = data.get("network_objects") or {}
vlans = data.get("vlan_interfaces") or {}
return set(network) if isinstance(network, dict) else set(), set(vlans) if isinstance(vlans, dict) else set()
def apply(
self,
customer: str,
fqdn: str,
*,
hostname: str,
network_objects: dict[str, dict[str, Any]],
vlan_interfaces: dict[str, dict[str, Any]],
) -> Path:
path = self.path(customer, fqdn)
yaml = self._yaml()
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
parsed_original = yaml.load(original_text) if original_text.strip() else None
data = self.load(customer, fqdn)
data["hostname"] = hostname
if network_objects:
existing = data.get("network_objects")
if existing is None:
existing = CommentedMap()
data["network_objects"] = existing
if not isinstance(existing, dict):
raise ValueError("network_objects exists but is not a YAML mapping; edit it manually before using AIM")
for key, value in network_objects.items():
existing[key] = CommentedMap(value)
if vlan_interfaces:
existing = data.get("vlan_interfaces")
if existing is None:
existing = CommentedMap()
data["vlan_interfaces"] = existing
if not isinstance(existing, dict):
raise ValueError("vlan_interfaces exists but is not a YAML mapping; edit it manually before using AIM")
for key, value in vlan_interfaces.items():
existing[key] = CommentedMap(value)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".aim-sophos-", suffix=".yml", dir=path.parent)
os.close(fd)
tmp = Path(tmp_name)
try:
with tmp.open("w", encoding="utf-8") as stream:
if parsed_original is None and original_text.strip():
stream.write(original_text.rstrip() + "\n\n# AIM-generated Sophos XGS configuration\n")
yaml.dump(data, stream)
# Parse the emitted candidate before replacing the live file.
with tmp.open("r", encoding="utf-8") as stream:
candidate = yaml.load(stream)
if candidate is not None and not isinstance(candidate, dict):
raise ValueError("Generated Sophos host_vars are not a YAML mapping")
if path.exists():
tmp.chmod(stat.S_IMODE(path.stat().st_mode))
if tmp.read_bytes() == path.read_bytes():
return path
else:
apply_managed_permissions(tmp, self.customers.config.required_group)
os.replace(tmp, path)
apply_managed_permissions(path, self.customers.config.required_group)
return path
finally:
tmp.unlink(missing_ok=True)
View File
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
import atexit
import base64
import os
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from aim.exceptions import ExternalCommandFailed
from aim.external import command_available, run_external
@dataclass(frozen=True)
class SSHKeyStatus:
private_key: Path
public_key: Path
complete: bool
partial: bool
class SSHManager:
def __init__(self, customers, config, vault=None):
self.customers = customers
self.config = config
self.vault = vault
self._agent_started = False
def key_path(self, customer: str) -> Path:
return self.customers.require(customer) / f"group_vars/linux/.ssh/{self.config.service_user}"
def status(self, customer: str) -> SSHKeyStatus:
private = self.key_path(customer)
public = Path(str(private) + ".pub")
return SSHKeyStatus(private, public, private.exists() and public.exists(), private.exists() != public.exists())
def ensure_key(self, customer: str) -> bool:
status = self.status(customer)
if status.complete:
return False
if status.partial:
raise ExternalCommandFailed(
f"Incomplete SSH keypair at {status.private_key}; resolve it before AIM generates a new key."
)
if not command_available("ssh-keygen"):
raise ExternalCommandFailed("Required command not found: ssh-keygen")
status.private_key.parent.mkdir(parents=True, exist_ok=True)
result = run_external(
["ssh-keygen", "-t", "ed25519", "-f", str(status.private_key), "-C", f"{self.config.service_user}@{customer}"],
cwd=self.customers.require(customer),
)
if result.returncode:
raise ExternalCommandFailed("ssh-keygen failed")
status.private_key.chmod(0o600)
status.public_key.chmod(0o644)
return True
def fingerprint(self, customer: str) -> str:
status = self.status(customer)
if not status.public_key.exists():
raise FileNotFoundError(status.public_key)
result = run_external(["ssh-keygen", "-lf", str(status.public_key)], cwd=self.customers.require(customer), capture_output=True)
if result.returncode:
raise ExternalCommandFailed(result.stderr.strip() or "Could not read SSH key fingerprint")
return result.stdout.strip()
def create_service_user(self, ip: str, bootstrap_user: str) -> None:
if not bootstrap_user:
raise ValueError("Bootstrap user is required")
target = self.config.service_user
remote = f'''set -e
TARGET_USER={target!r}
if ! id "$TARGET_USER" >/dev/null 2>&1; then
sudo useradd -m -s /bin/bash "$TARGET_USER"
fi
sudo mkdir -p "/home/$TARGET_USER/.ssh"
sudo chown "$TARGET_USER:$TARGET_USER" "/home/$TARGET_USER/.ssh"
sudo chmod 700 "/home/$TARGET_USER/.ssh"
if command -v usermod >/dev/null 2>&1; then
sudo usermod -aG sudo "$TARGET_USER" 2>/dev/null || true
sudo usermod -aG wheel "$TARGET_USER" 2>/dev/null || true
fi
echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" | sudo tee "/etc/sudoers.d/$TARGET_USER" >/dev/null
sudo chmod 0440 "/etc/sudoers.d/$TARGET_USER"
'''
result = run_external(["ssh", "-t", f"{bootstrap_user}@{ip}", remote], cwd=self.config.root_dir)
if result.returncode:
raise ExternalCommandFailed("Remote service-user creation failed")
def install_key(self, customer: str, ip: str, bootstrap_user: str) -> None:
if not bootstrap_user:
raise ValueError("Bootstrap user is required")
self.ensure_key(customer)
status = self.status(customer)
pub = status.public_key.read_text(encoding="utf-8").strip()
encoded = base64.b64encode(pub.encode("utf-8")).decode("ascii")
target = self.config.service_user
remote = f'''set -e
TARGET_USER={target!r}
PUBKEY=$(printf %s {encoded!r} | base64 -d)
if ! id "$TARGET_USER" >/dev/null 2>&1; then
echo "Target user $TARGET_USER does not exist." >&2
exit 1
fi
sudo mkdir -p "/home/$TARGET_USER/.ssh"
sudo touch "/home/$TARGET_USER/.ssh/authorized_keys"
if ! sudo grep -qxF "$PUBKEY" "/home/$TARGET_USER/.ssh/authorized_keys"; then
printf '%s\\n' "$PUBKEY" | sudo tee -a "/home/$TARGET_USER/.ssh/authorized_keys" >/dev/null
fi
sudo chown -R "$TARGET_USER:$TARGET_USER" "/home/$TARGET_USER/.ssh"
sudo chmod 700 "/home/$TARGET_USER/.ssh"
sudo chmod 600 "/home/$TARGET_USER/.ssh/authorized_keys"
# Ensure public-key authentication is effectively enabled without changing unrelated SSH settings.
SSHD=$(command -v sshd || true)
if [ -n "$SSHD" ]; then
if ! sudo "$SSHD" -T 2>/dev/null | grep -qi '^pubkeyauthentication yes$'; then
if [ -d /etc/ssh/sshd_config.d ]; then
printf '%s\\n' 'PubkeyAuthentication yes' | sudo tee /etc/ssh/sshd_config.d/90-aim.conf >/dev/null
else
if sudo grep -qiE '^\\s*PubkeyAuthentication\\s+' /etc/ssh/sshd_config; then
sudo sed -i -E 's/^\\s*#?\\s*PubkeyAuthentication\\s+.*/PubkeyAuthentication yes/I' /etc/ssh/sshd_config
else
printf '%s\\n' 'PubkeyAuthentication yes' | sudo tee -a /etc/ssh/sshd_config >/dev/null
fi
fi
sudo "$SSHD" -t
sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh 2>/dev/null || sudo service sshd reload 2>/dev/null || sudo service ssh reload
fi
fi
'''
result = run_external(["ssh", "-t", f"{bootstrap_user}@{ip}", remote], cwd=self.config.root_dir)
if result.returncode:
raise ExternalCommandFailed("SSH key installation / PubkeyAuthentication preparation failed")
def _ensure_agent(self) -> None:
if os.environ.get("SSH_AUTH_SOCK"):
return
if not command_available("ssh-agent"):
raise ExternalCommandFailed("Required command not found: ssh-agent")
result = run_external(["ssh-agent", "-s"], capture_output=True)
if result.returncode:
raise ExternalCommandFailed(result.stderr.strip() or "Could not start ssh-agent")
for chunk in result.stdout.split(";"):
chunk = chunk.strip()
if chunk.startswith("SSH_AUTH_SOCK=") or chunk.startswith("SSH_AGENT_PID="):
key, value = chunk.split("=", 1)
os.environ[key] = value
if not os.environ.get("SSH_AUTH_SOCK"):
raise ExternalCommandFailed("ssh-agent did not provide SSH_AUTH_SOCK")
self._agent_started = True
atexit.register(self._shutdown_agent)
def _shutdown_agent(self) -> None:
if not self._agent_started:
return
pid = os.environ.get("SSH_AGENT_PID", "")
if pid.isdigit():
try:
os.kill(int(pid), 15)
except OSError:
pass
self._agent_started = False
def ensure_key_loaded(self, customer: str) -> bool:
"""Load the customer's encrypted key into ssh-agent using its Vault passphrase."""
key = self.key_path(customer)
if not key.exists():
raise FileNotFoundError(key)
if not command_available("ssh-add"):
raise ExternalCommandFailed("Required command not found: ssh-add")
self._ensure_agent()
pub = Path(str(key) + ".pub")
if pub.exists():
fingerprint = self.fingerprint(customer).split()[1]
listed = run_external(["ssh-add", "-l"], capture_output=True)
if listed.returncode == 0 and fingerprint in listed.stdout:
return False
if self.vault is None:
raise ExternalCommandFailed("Vault integration is not available for SSH key loading")
passphrase = self.vault.get_value(customer, "vault_linux_ssh_key_passphrase")
if not passphrase:
raise ExternalCommandFailed(
"Private SSH key is encrypted, but vault_linux_ssh_key_passphrase is empty or missing in the customer Vault"
)
with tempfile.TemporaryDirectory(prefix="aim-askpass-") as tmp_name:
helper = Path(tmp_name) / "askpass.sh"
helper.write_text('#!/bin/sh\nprintf "%s\\n" "$AIM_SSH_KEY_PASSPHRASE"\n', encoding="utf-8")
helper.chmod(0o700)
env = os.environ.copy()
env.update({
"SSH_ASKPASS": str(helper),
"SSH_ASKPASS_REQUIRE": "force",
"DISPLAY": env.get("DISPLAY", ":0"),
"AIM_SSH_KEY_PASSPHRASE": passphrase,
})
result = run_external(["ssh-add", str(key)], cwd=self.customers.require(customer), capture_output=True, env=env)
if result.returncode:
raise ExternalCommandFailed(result.stderr.strip() or "Could not load SSH key into ssh-agent")
return True
def test_connection(self, customer: str, ip: str) -> None:
key = self.key_path(customer)
if not key.exists():
raise FileNotFoundError(key)
self.ensure_key_loaded(customer)
result = run_external(
["ssh", "-i", str(key), "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", f"{self.config.service_user}@{ip}", "true"],
cwd=self.customers.require(customer),
capture_output=True,
)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
raise ExternalCommandFailed(detail or "SSH connection test failed")
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from dataclasses import dataclass, field
from io import StringIO
from pathlib import Path
import re
from ruamel.yaml import YAML
from aim.exceptions import ValidationFailed
from aim.permissions import apply_managed_permissions
from aim.templates import group_var_templates, VAULT_TEMPLATE_KEYS, VAULT_TEMPLATE_SECTIONS
@dataclass
class DiffLine:
marker: str
text: str
line: int | None = None
kind: str = "info"
@dataclass
class FileDiff:
path: Path
lines: list[DiffLine] = field(default_factory=list)
syntax_error: str | None = None
@property
def has_deviations(self) -> bool:
return bool(self.syntax_error or self.lines)
def _key_lines(text: str) -> dict[str, int]:
result: dict[str, int] = {}
for no, line in enumerate(text.splitlines(), 1):
if not line or line.lstrip().startswith("#") or line[:1].isspace():
continue
match = re.match(r"^([A-Za-z0-9_.-]+)\s*:", line)
if match and match.group(1) not in result:
result[match.group(1)] = no
return result
def compare_plain_yaml(path: Path, expected_header: str, expected: dict[str, object]) -> FileDiff:
diff = FileDiff(path)
if not path.exists():
diff.lines.append(DiffLine("+", "file missing; AIM template would create it", None, "missing"))
return diff
text = path.read_text(encoding="utf-8")
try:
data = YAML(typ="safe").load(text) or {}
if not isinstance(data, dict):
raise ValueError("YAML root is not a mapping")
except Exception as exc:
diff.syntax_error = str(exc)
return diff
lines = _key_lines(text)
header = expected_header.strip()
if header and header not in text:
diff.lines.append(DiffLine("+", header, None, "comment"))
for key, expected_value in expected.items():
if key not in data:
diff.lines.append(DiffLine("+", f"{key}: {expected_value!r}", None, "missing"))
continue
current = data[key]
if current != expected_value:
no = lines.get(key)
diff.lines.append(DiffLine("-", f"{key}: {expected_value!r}", no, "deviation"))
diff.lines.append(DiffLine("+", f"{key}: {current!r}", no, "deviation"))
for key, value in data.items():
if key not in expected:
diff.lines.append(DiffLine("+", f"{key}: {value!r} # custom, retained", lines.get(key), "custom"))
return diff
def consolidate_plain_yaml(path: Path, expected_header: str, expected: dict[str, object], group: str) -> bool:
yaml = YAML()
yaml.preserve_quotes = True
data = {}
original = ""
if path.exists():
original = path.read_text(encoding="utf-8")
try:
data = yaml.load(original) or {}
except Exception as exc:
raise ValidationFailed(f"Invalid YAML in {path}: {exc}") from exc
if not isinstance(data, dict):
raise ValidationFailed(f"YAML root must be a mapping: {path}")
changed = False
for key, value in expected.items():
if key not in data:
data[key] = value
changed = True
header = expected_header.strip()
need_header = bool(header and header not in original)
if not changed and not need_header:
return False
out = StringIO()
if need_header:
out.write(expected_header.rstrip() + "\n")
yaml.dump(data, out)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(out.getvalue(), encoding="utf-8")
apply_managed_permissions(path, group)
return True
def customer_template_diffs(customer_root: Path, service_user: str) -> list[FileDiff]:
templates = group_var_templates(str(customer_root), service_user)
return [compare_plain_yaml(customer_root / rel, header, dict(data)) for rel, (header, data) in templates.items()]
def consolidate_customer_templates(customer_root: Path, service_user: str, group: str) -> tuple[list[Path], list[str]]:
changed: list[Path] = []
skipped: list[str] = []
for rel, (header, data) in group_var_templates(str(customer_root), service_user).items():
path = customer_root / rel
if not path.parent.exists():
continue
try:
if consolidate_plain_yaml(path, header, dict(data), group):
changed.append(path)
except ValidationFailed as exc:
skipped.append(str(exc))
return changed, skipped
def vault_structure_from_text(text: str) -> tuple[dict[str, bool], list[str], set[str]]:
"""Parse YAML correctly; expose only variable names and empty/non-empty state."""
data = YAML().load(text)
if data is None:
data = {}
if not isinstance(data, dict):
raise ValidationFailed("Vault content must be a YAML mapping; check spaces after mapping colons")
states = {str(key): value is not None and value != "" for key, value in data.items()}
known_comments = {comment for comment, _ in VAULT_TEMPLATE_SECTIONS}
comments = {line.strip() for line in text.splitlines() if line.strip() in known_comments}
return states, list(states), comments
def compare_vault_structure(path: Path, decrypted_text: str) -> FileDiff:
diff = FileDiff(path)
states, order, comments = vault_structure_from_text(decrypted_text)
parsed = YAML().load(decrypted_text)
locations = {str(k): parsed.lc.key(k)[0] + 1 for k in parsed} if isinstance(parsed, dict) and hasattr(parsed, "lc") else {}
for comment, key in VAULT_TEMPLATE_SECTIONS:
if comment not in comments:
diff.lines.append(DiffLine("+", comment, None, "comment"))
if key:
if key not in states:
diff.lines.append(DiffLine("+", f'{key}: "" # missing', None, "missing"))
elif states[key]:
# Set is expected/valid; no secret is rendered.
pass
else:
diff.lines.append(DiffLine("!", f'{key}: "" # present but not set', locations.get(key), "unset"))
for key in order:
if key not in VAULT_TEMPLATE_KEYS:
status = "set" if states.get(key) else "not set"
diff.lines.append(DiffLine("+", f'{key}: "********" # custom, {status}, retained', locations.get(key), "custom"))
return diff
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from collections import OrderedDict
def group_var_templates(customer_path: str, service_user: str) -> dict[str, tuple[str, OrderedDict[str, object]]]:
return {
"group_vars/linux/main.yml": (
"# Linux / SSH variables\n",
OrderedDict([
("ansible_connection", "ssh"),
("ansible_user", service_user),
("ansible_private_key_file", f"{customer_path}/group_vars/linux/.ssh/{service_user}"),
("ansible_ssh_pass", "{{ ansible_vault_linux_ssh_pass }}"),
("ansible_become_method", "sudo"),
]),
),
"group_vars/windows/main.yml": (
"# Windows / WinRM variables\n",
OrderedDict([
("ansible_connection", "winrm"),
("ansible_port", 5986),
("ansible_winrm_transport", "ntlm"),
("ansible_winrm_server_cert_validation", "ignore"),
("ansible_user", service_user),
("ansible_password", "{{ vault_windows_ansible_password }}"),
]),
),
"group_vars/sophosxgs/main.yml": (
"# SophosXGS-specific variables\n",
OrderedDict([
("ansible_user", "admin"),
("ansible_connection", "ansible.netcommon.httpapi"),
("ansible_httpapi_validate_certs", False),
("ansible_httpapi_port", 4444),
("ansible_network_os", "sophos.sophos_firewall.sfos"),
]),
),
"group_vars/pfsense/main.yml": ("# pfSense-specific variables\n", OrderedDict()),
}
VAULT_TEMPLATE_SECTIONS = (
("# Windows - domain service account", "vault_windows_ansible_password"),
("# Windows - optional shared local service account", "vault_windows_local_ansible_password"),
("# Windows - per-host local account overrides", None),
('# Example: vault_ansible_password_server01_example_lan: ""', None),
("# Linux - legacy SSH password", "ansible_vault_linux_ssh_pass"),
("# Linux - private-key passphrase", "vault_linux_ssh_key_passphrase"),
)
VAULT_TEMPLATE_KEYS = tuple(key for _, key in VAULT_TEMPLATE_SECTIONS if key)
View File
+286
View File
@@ -0,0 +1,286 @@
"""Operator workflows for SSH, WinRM and domain policy rollout.
Credentials remain in the existing managers. UI reviews show identities, never
passwords, and normal connection tests use Inventory/Vault credentials.
"""
from __future__ import annotations
from pathlib import Path
import re
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
from aim.exceptions import AIMError, OperationCancelled
from aim.ui.components import MenuItem
from aim.ui.selection import Choice
class AccessScreens:
def access_menu(self, customer: str, inventory_path: Path) -> None:
while True:
self.clear()
self.header(self.breadcrumb(customer, 'Access'))
choice = self.menu([
MenuItem('1', 'Linux / SSH', 'Key management, user preparation and access tests'),
MenuItem('2', 'Windows / WinRM', 'Local, domain and GPO workflows'),
])
if choice == '0':
return
self.call(self.linux_ssh_menu if choice == '1' else self.winrm_menu, customer, inventory_path)
def winrm_menu(self, customer: str, inventory_path: Path) -> None:
actions = {
'1': self.windows_temporary, '2': self.windows_local, '3': self.windows_domain,
'4': self.windows_grant, '5': self.windows_rollout, '6': self.windows_test,
}
while True:
self.clear()
self.header(self.breadcrumb(customer, 'Access > Windows / WinRM'), 'NTLM / HTTPS :5986. WinRM must already be prepared on bootstrap targets.')
choice = self.menu([
MenuItem('1', 'Test temporary access', 'Existing administrator credentials'),
MenuItem('2', 'Prepare local service account', 'Standalone hosts; host-specific credentials'),
MenuItem('3', 'Prepare domain service account', 'One DC per domain; privileged AD change'),
MenuItem('4', 'Enable access on member servers', 'Manual fallback; existing domain identity'),
MenuItem('5', 'Deploy / repair WinRM GPO', 'Prepared DC; uses configured service account'),
MenuItem('6', 'Test Ansible WinRM access', 'Configured inventory + Vault; no admin login prompt'),
])
if choice == '0':
return
self.call(actions[choice], customer, inventory_path)
def _windows_targets(self, customer: str, inventory_path: Path, title: str):
return self.select_targets(customer, inventory_path, ('windows',), title=title, require_address=True)
def _domain_identity(self, customer: str):
defaults = self.customers.load_defaults(customer)
domain = self.required_value('AD DNS domain', defaults.ad_dns_domain or defaults.domain_suffix or None).lower().rstrip('.')
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', domain):
raise ValueError('AD DNS domain must be a DNS name, not a UPN or DOMAIN\\user.')
return domain, f'{self.config.service_user}@{domain}'
def _bootstrap_domain_credentials(self, customer: str, domain: str):
defaults = self.customers.load_defaults(customer)
# Respect the operator's configured down-level domain, rather than guessing
# its NetBIOS name from the first DNS label.
default_user = f'{defaults.ad_netbios_domain}\\Administrator' if defaults.ad_netbios_domain else f'Administrator@{domain}'
username = self.required_value('Temporary domain administrator (UPN or DOMAIN\\user)', default_user)
if '@' not in username and '\\' not in username:
username = f'{defaults.ad_netbios_domain}\\{username}' if defaults.ad_netbios_domain else f'{username}@{domain}'
self.ui.details([('Bootstrap identity', username)])
password = self.ask('Temporary administrator password', password=True)
return username, password
def _new_service_password(self):
password = self.ask(f'Password for {self.config.service_user}', password=True)
if not password:
raise ValueError('The service-account password cannot be empty.')
if password != self.ask('Confirm service-account password', password=True):
raise ValueError('Service-account passwords do not match.')
return password
def _access_batch(self, title, targets, operation):
results = []
for index, host in enumerate(targets, 1):
self.ui.result(f'{title} [{index}/{len(targets)}] {host.fqdn}', level='info')
try:
operation(host)
except OperationCancelled:
raise
except (AIMError, OSError, ValueError, YAMLError) as exc:
results.append((host.fqdn, 'Failed'))
self.show_error(exc)
else:
results.append((host.fqdn, 'Succeeded'))
self.ui.table(['Host', 'Result'], results)
failed = sum(state == 'Failed' for _, state in results)
self.ui.result(f'{len(results)-failed} succeeded; {failed} failed.',
level='warning' if failed else 'success', title=title)
self.pause()
def windows_temporary(self, customer: str, inventory_path: Path) -> None:
targets, label = self._windows_targets(customer, inventory_path, 'Access > Windows > Test temporary access')
if not targets:
return
username = self.required_value('Temporary administrator (UPN, DOMAIN\\user or local user)')
password = self.ask('Temporary administrator password', password=True)
self.ui.review('Test temporary access', [('Targets', '\n'.join(h.fqdn for h in targets)), ('Identity', username), ('Transport', 'NTLM / HTTPS :5986')])
if self.confirm('Test these hosts?', default=False):
self._access_batch('Temporary WinRM access', targets, lambda h: self.winrm.test_connection(h.fqdn, h.ansible_host, username, password))
def windows_local(self, customer: str, inventory_path: Path) -> None:
targets, label = self._windows_targets(customer, inventory_path, 'Access > Windows > Prepare local account')
if not targets:
return
bootstrap = self.required_value('Temporary administrator', 'Administrator')
bootstrap_password = self.ask('Temporary administrator password', password=True)
service_password = self._new_service_password()
self.ui.result('Existing host credential overrides are preserved unless you explicitly replace them below.', level='info')
update_vars = self.confirm('Set selected hosts to the shared-local Vault credential model?', default=False)
self.ui.review('Prepare local service account', [
('Targets', '\n'.join(h.fqdn for h in targets)), ('Bootstrap identity', bootstrap),
('Local account', self.config.service_user), ('Group', 'Local Administrators'),
('Host variables', 'Set shared-local credential references' if update_vars else 'Retain existing references'),
('Vault variable', 'vault_windows_local_ansible_password'),
], warning='Existing service-account passwords may be reset. Domain-wide group variables are not changed. Store the matching password in Vault.')
if not self.confirm('Prepare these local accounts?', default=False):
return
def prepare(host):
self.winrm.bootstrap_service_user(host.fqdn, host.ansible_host, bootstrap, bootstrap_password, service_password)
if update_vars:
self.customers.set_windows_host_credential_model(customer, host.fqdn, 'shared_local')
self._access_batch('Local account preparation', targets, prepare)
def windows_domain(self, customer: str, inventory_path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, 'Access > Windows > Prepare domain account'))
host = self.pick_one_host(self.inventory_hosts(customer, inventory_path, ('windows',), require_address=True), 'Select administration DC (one host)')
if not host:
return
domain, service_upn = self._domain_identity(customer)
bootstrap, bootstrap_password = self._bootstrap_domain_credentials(customer, domain)
service_password = self._new_service_password()
# Validate the local destination before making the remote AD change.
variables = self.customers.require(customer) / 'group_vars/windows/main.yml'
if variables.exists():
with variables.open(encoding='utf-8') as stream:
data = YAML().load(stream)
if data is not None and not isinstance(data, dict):
raise ValueError(f'Windows group variables must be a YAML mapping: {variables}')
self.ui.review('Prepare domain service account', [
('Administration DC', host.fqdn), ('AD DNS domain', domain), ('Bootstrap identity', bootstrap),
('Service identity', service_upn), ('AD membership', 'BUILTIN\\Administrators'), ('Inventory update', variables),
], warning='This grants privileged access to domain controllers. An existing password may be reset. It is not merely member-server access.')
if not self.confirm('Create / repair this domain account?', default=False):
return
service_upn = self.winrm.bootstrap_domain_service_user(host.fqdn, host.ansible_host, bootstrap, bootstrap_password, domain, service_password)
self.ui.result('AD account preparation completed. Updating the inventory reference next.', level='info')
try:
self.customers.set_windows_ansible_user(customer, service_upn)
except (AIMError, OSError, ValueError, YAMLError) as exc:
self.ui.result('The AD operation succeeded, but the inventory reference was not saved. Do not repeat the password reset just to fix YAML.', level='warning')
raise
self.ui.result(f'Domain service account prepared: {service_upn}', title='Domain account ready')
self.ui.result('Store the matching password in vault_windows_ansible_password. Use GPO rollout or the manual member-server action for member access.', level='info')
self.pause()
def windows_grant(self, customer: str, inventory_path: Path) -> None:
targets, label = self.pick_windows_member_targets(customer, inventory_path)
if not targets:
return
domain, service_upn = self._domain_identity(customer)
bootstrap, bootstrap_password = self._bootstrap_domain_credentials(customer, domain)
self.ui.review('Enable member-server access', [
('Targets', '\n'.join(h.fqdn for h in targets)), ('Domain account', service_upn),
('Bootstrap identity', bootstrap), ('Change', 'Add the existing account to local Administrators'),
], warning='Domain controllers are detected and skipped by the grant operation. Standalone hosts may reject domain credentials; other targets continue.')
if not self.confirm('Grant access on these member servers?', default=False):
return
self.winrm.grant_domain_service_user_access_batch([(h.fqdn, h.ansible_host) for h in targets], bootstrap, bootstrap_password, domain)
self.ui.result('Member-server batch completed. Check the Ansible recap for skipped targets.', title='Access rollout processed')
self.pause()
def windows_rollout(self, customer: str, inventory_path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, 'Access > Windows > WinRM GPO rollout'), 'Uses the existing inventory / Vault service account. No temporary administrator credentials.')
dc = self.pick_one_host(self.inventory_hosts(customer, inventory_path, ('windows',), require_address=True), 'Select prepared administration DC (one host)')
if not dc:
return
self.ui.result('Checking configured WinRM access to the DC...', level='info')
self.winrm.test_inventory_service_user(customer, inventory_path, dc.fqdn)
self.ui.result('Querying organizational units...', level='info')
ous = self.winrm.list_domain_ous(customer, inventory_path, dc.fqdn)
selected = self.selector.select('Select GPO link target', ['OU', 'Distinguished name'],
[Choice((name, dn), (name, dn), (dn,)) for name, dn in ous])
if not selected:
return
_, ou_dn = selected
extra = self.ask('Additional certificate DNS/IP SANs (comma-separated; optional)', default='', show_default=False)
extra_sans = [item.strip() for item in extra.split(',') if item.strip()]
self.ui.review('Deploy / repair domain WinRM GPO', [
('Administration DC', dc.fqdn), ('Credentials', 'Configured inventory + Vault'),
('Target OU', ou_dn), ('Security group', 'GG_bitformer_Ansible_Admins'),
('GPO', 'bitformer - Ansible WinRM'), ('Certificate', 'Self-signed'),
('Additional SANs', ', '.join(extra_sans) or 'None'),
('Task timing', 'Immediate registration; 2-minute boot delay; 5-minute retries for 1 hour'),
], warning='This links a computer policy to the selected OU. It grants the Ansible group local administrative access on applicable member servers.')
self.ui.footer('review')
if not self.confirm('Deploy / repair this policy?', default=False):
return
self.winrm.deploy_domain_winrm_gpo(customer, inventory_path, dc.fqdn, ou_dn, extra_sans)
self.ui.result('Policy created / repaired and linked to the selected OU.', title='GPO deployment completed')
self.ui.result('Policy propagation is not immediate. Run gpupdate /force on a member to accelerate processing. The task starts on registration, retries if needed, and disables after verification. AIM has not verified client application or connectivity.', level='warning')
self.pause()
def windows_test(self, customer: str, inventory_path: Path) -> None:
targets, label = self._windows_targets(customer, inventory_path, 'Access > Windows > Test Ansible access')
if not targets:
return
self.ui.review('Test Ansible WinRM access', [('Targets', '\n'.join(h.fqdn for h in targets)),
('Credentials', 'Existing group_vars / host_vars / Vault; no temporary admin credentials')])
if not self.confirm('Run the configured connection test?', default=False):
return
# One ad-hoc invocation means one Vault prompt for the selected batch.
self.winrm.test_inventory_service_user(customer, inventory_path, ':'.join(h.fqdn for h in targets))
self.ui.result('Configured WinRM test succeeded for the selected hosts.', title='Connection test completed')
self.pause()
def linux_ssh_menu(self, customer: str, inventory_path: Path) -> None:
while True:
self.clear()
status = self.ssh.status(customer)
state = 'Keypair present' if status.complete else 'Incomplete keypair' if status.partial else 'Not created'
self.header(self.breadcrumb(customer, 'Access > Linux / SSH'), f'Customer SSH key: {state}')
choice = self.menu([
MenuItem('1', 'Inspect keypair', 'Paths and public-key fingerprint'),
MenuItem('2', 'Create keypair', 'Preserve existing keys'),
MenuItem('3', 'Prepare service user and key', 'Create user, grant sudo, install authorized key'),
MenuItem('4', 'Install key only', 'Existing service account required'),
MenuItem('5', 'Test Ansible SSH access', 'Configured customer key and service user'),
])
if choice == '0':
return
self.call(self.linux_action, customer, inventory_path, choice)
def linux_action(self, customer: str, inventory_path: Path, choice: str) -> None:
status = self.ssh.status(customer)
if choice == '1':
self.clear()
self.header(self.breadcrumb(customer, 'Access > Linux / SSH > Keypair'))
rows = [('Private key', status.private_key), ('Public key', status.public_key), ('Status', 'Present' if status.complete else 'Incomplete / absent')]
if status.complete:
rows.append(('Fingerprint', self.ssh.fingerprint(customer)))
self.ui.details(rows)
self.pause()
return
if choice == '2':
if status.complete:
self.ui.result('Keypair already exists. It was not changed.', level='info')
else:
self.ui.review('Create SSH keypair', [('Private key', status.private_key), ('Public key', status.public_key)])
if not self.confirm('Create this keypair?', default=False):
return
self.ssh.ensure_key(customer)
self.ui.result('SSH keypair created.', title='Keypair ready')
self.pause()
return
targets, label = self.select_targets(customer, inventory_path, ('linux',), title='Access > Linux / SSH > Select targets', require_address=True)
if not targets:
return
bootstrap = None if choice == '5' else self.required_value('One-time SSH login user (sudo-capable)')
self.ui.review('Review SSH operation', [
('Operation', {'3': 'Prepare user and key', '4': 'Install key only', '5': 'Test key authentication'}[choice]),
('Targets', '\n'.join(h.fqdn for h in targets)), ('Service account', self.config.service_user),
('Login', bootstrap or 'Customer key / ssh-agent'), ('Private key', status.private_key),
], warning='User preparation grants passwordless sudo. Native SSH/sudo prompts remain interactive.' if choice == '3' else '')
if not self.confirm('Proceed with this SSH operation?', default=False):
return
if choice in {'3', '4'}:
self.ssh.ensure_key(customer)
def operation(host):
if choice == '3':
self.ssh.create_service_user(host.ansible_host, bootstrap)
self.ssh.install_key(customer, host.ansible_host, bootstrap)
elif choice == '4':
self.ssh.install_key(customer, host.ansible_host, bootstrap)
else:
self.ssh.test_connection(customer, host.ansible_host)
self._access_batch('SSH operation', targets, operation)
+283
View File
@@ -0,0 +1,283 @@
"""Read-only inspection, explicit consolidation and application settings."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import fields
from pathlib import Path
import os
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
from aim.auth import require_group_membership
from aim.exceptions import AIMError
from aim.inventory.validator import validate
from aim.structure import customer_template_diffs, consolidate_customer_templates, compare_vault_structure
from aim.ui.components import MenuItem, text
from aim.ui.selection import Choice
class AdministrationScreens:
def variables_menu(self, customer: str, kind: str, title: str) -> None:
while True:
files = self.variables_viewer.files(customer, kind)
choices = [Choice(p, (str(self.variables_viewer.relative(customer, p)),)) for p in files]
path = self.selector.select(f'{title} > Select file', ['YAML file (read-only)'], choices)
if path is None:
return
with path.open('rb') as stream:
encrypted = stream.read(14).startswith(b'$ANSIBLE_VAULT')
if encrypted:
self.ui.result('Encrypted Vault file. Contents are not decrypted in the variable viewer. Use Vault > Edit to edit it.', level='info')
self.pause()
else:
self.view_text(str(self.variables_viewer.relative(customer, path)), path.read_text(encoding='utf-8'))
def vault_menu(self, customer: str) -> None:
while True:
self.clear()
self.header(self.breadcrumb(customer, 'Vault'), 'Secret input is hidden; the dashboard never decrypts this file.')
choice = self.menu([
MenuItem('1', 'Inspect Vault status', 'Path, Vault identity and editor'),
MenuItem('2', 'Create Vault', 'Template prompts; encrypt before use'),
MenuItem('3', 'Edit Vault', 'Open the configured editor'),
MenuItem('4', 'Delete Vault', 'Remove the encrypted file', True),
])
if choice == '0':
return
self.call(self.vault_action, customer, choice)
def create_vault(self, customer: str) -> None:
if self.vault.status(customer).exists:
self.ui.result('Vault already exists. No secrets were requested and no file was changed.', level='info')
return
values = self.prompt_vault_template_values()
try:
self.ui.review('Create encrypted Vault', [
('File', self.vault.path(customer)), ('Vault identity', f'{customer}@prompt'),
('Values supplied', sum(bool(v) for v in values.values())),
('Values left empty', sum(not bool(v) for v in values.values())),
])
if self.confirm('Create and encrypt the Vault?', default=False):
created = self.vault.create(customer, values)
self.ui.result('Vault created and encrypted.' if created else 'Vault already exists; no change.', title='Vault result')
finally:
values.clear()
def vault_action(self, customer: str, choice: str) -> None:
status = self.vault.status(customer)
if choice == '1':
self.ui.details([('Path', status.path), ('Status', self.snapshot(customer)['vault']),
('Vault identity', f'{customer}@prompt'),
('Editor', os.environ.get('VISUAL') or os.environ.get('EDITOR') or 'nano')], title='Vault information')
elif choice == '2':
self.create_vault(customer)
elif choice == '3':
self.ui.result('Opening the Vault editor. The editor displays decrypted content for this explicit edit action.', level='info')
self.vault.edit(customer)
self.ui.result('Vault editor closed successfully.', title='Vault editor')
elif choice == '4':
self.ui.review('Delete Vault', [('File', status.path)], warning='This removes all secrets in this Vault. The hosts.yml session backup does not restore it.')
if self.confirm('Delete this encrypted Vault?', default=False):
self.vault.delete(customer)
self.ui.result('Vault deleted.', title='Vault removed')
self.pause()
def administration_menu(self, customer: str, path: Path) -> None:
while True:
self.clear()
state = 'Available for this session' if self.backups.has_session_backup(path) else 'Not created in this session'
self.header(self.breadcrumb(customer, 'Administration'), f'hosts.yml recovery: {state}')
choice = self.menu([
MenuItem('1', 'Validate inventory and templates', 'Read-only; values in Vault diagnostics are masked'),
MenuItem('2', 'Add missing template structure', 'Preview and explicitly confirm consolidation'),
MenuItem('3', 'Restore hosts.yml session backup', 'Replace current inventory only', True),
MenuItem('4', 'Customer defaults', 'Hostname, network and AD defaults'),
])
if choice == '0':
return
func = {'1': self.validate_inventory, '2': self.consolidate_templates, '3': self.restore, '4': self.customer_defaults_screen}[choice]
self.call(func, customer, path)
def customer_defaults_screen(self, customer: str, path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, 'Administration > Customer defaults'))
self.configure_customer_defaults(customer)
def _render_template_diff(self, title: str, lines) -> None:
if not lines:
self.ui.result(f'{title}: no template deviations detected.')
return
self.console.print(text(title, 'bold'))
self.console.print(text('Missing entries are proposed additions; differing/custom values are retained.', 'aim.muted'))
for item in lines:
location = f'L{item.line}' if item.line else '--'
style = 'aim.warning' if item.kind in {'missing', 'unset', 'deviation'} else 'aim.context'
self.console.print(text(f'{item.marker} {location:>5} {item.text}', style))
def validate_inventory(self, customer: str, path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, 'Administration > Validate'), 'Read-only: syntax, Ansible parsing and template comparison. No host connection test.')
root = path.parent
vault = self.vault.path(customer)
failures = 0
checked = 0
encrypted = 0
for candidate in sorted(set(root.rglob('*.yml')) | set(root.rglob('*.yaml'))):
try:
with candidate.open('rb') as stream:
if stream.read(14).startswith(b'$ANSIBLE_VAULT'):
encrypted += 1
continue
with candidate.open(encoding='utf-8') as stream:
YAML().load(stream)
checked += 1
except YAMLError as exc:
failures += 1
mark = getattr(exc, 'problem_mark', None)
where = f'line {mark.line+1}, column {mark.column+1}' if mark else 'unknown location'
self.ui.result(f'{candidate.relative_to(root)}: YAML syntax error at {where}. Source values are not printed.', level='error')
except OSError as exc:
failures += 1
self.show_error(exc)
self.ui.result(f'Local syntax: {checked} readable YAML files passed; {failures} failed; {encrypted} encrypted files deferred to Ansible.', level='warning' if failures else 'success')
self.ui.result('Running full Ansible inventory validation. Vault passwords may be requested by Ansible.', level='info')
try:
validate(path, customer=customer)
except (AIMError, OSError, ValueError, YAMLError) as exc:
failures += 1
self.show_error(exc)
else:
self.ui.result('Ansible inventory parsing passed.')
for diff in customer_template_diffs(root, self.config.service_user):
if diff.syntax_error:
self.ui.result(f'{diff.path.relative_to(root)}: template comparison skipped because YAML is invalid.', level='warning')
else:
self._render_template_diff(str(diff.path.relative_to(root)), diff.lines)
if vault.exists():
self.ui.result('Reading Vault structure for a masked comparison. This may request the Vault password again.', level='info')
value = None
try:
value = self.vault.view_text(customer)
diff = compare_vault_structure(vault, value)
except (AIMError, OSError, ValueError, YAMLError) as exc:
failures += 1
self.show_error(exc)
else:
value = None
self._render_template_diff('group_vars/all/vault.yml (values masked)', diff.lines)
finally:
value = None
self.ui.result('Read-only checks finished. Template differences are informational; syntax/Ansible errors need attention.' if failures else 'Read-only checks finished. No files were changed.',
level='warning' if failures else 'success', title='Validation finished')
self.pause()
def consolidate_templates(self, customer: str, path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, 'Administration > Consolidate'), 'Add missing keys/comments; preserve existing values and custom variables.')
root = path.parent
for diff in customer_template_diffs(root, self.config.service_user):
if diff.syntax_error:
self.ui.result(f'{diff.path.relative_to(root)}: invalid YAML; this file will be skipped without changes.', level='warning')
else:
self._render_template_diff(str(diff.path.relative_to(root)), diff.lines)
include_vault = self.vault.path(customer).exists() and self.confirm('Preview and include the encrypted Vault template?', default=False)
if include_vault:
value = self.vault.view_text(customer)
try:
diff = compare_vault_structure(self.vault.path(customer), value)
finally:
value = None
self._render_template_diff('group_vars/all/vault.yml (values masked)', diff.lines)
self.ui.review('Apply consolidation', [('Customer', customer), ('Plaintext templates', 'Add missing keys/comments only'),
('Vault', 'Include after re-encryption' if include_vault else 'Leave unchanged')],
warning='Existing values are not normalized. Malformed plaintext files are skipped. This is not a repair of invalid YAML syntax.')
if not self.confirm('Apply non-destructive consolidation?', default=False):
return
changed, skipped = consolidate_customer_templates(root, self.config.service_user, self.config.required_group)
for item in changed:
self.ui.result(f'Consolidated {item.relative_to(root)}.')
# Avoid printing stored parser snippets which may contain secret values.
for item in skipped:
self.ui.result(item.split(':', 1)[0] + ': skipped; invalid YAML (file left untouched).', level='warning')
vault_changed = self.vault.consolidate_template(customer) if include_vault else False
if vault_changed:
self.ui.result('Consolidated and re-encrypted group_vars/all/vault.yml.')
self.ui.result(f'{len(changed) + int(vault_changed)} files changed; {len(skipped)} skipped.',
level='warning' if skipped else 'success', title='Consolidation finished')
self.pause()
def restore(self, customer: str, path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, 'Administration > Recover hosts.yml'))
if not self.backups.has_session_backup(path):
self.ui.result('No hosts.yml recovery backup was created in this AIM session.', level='info')
self.pause()
return
self.ui.review('Restore session backup', [('Backup', self.backups.backup_path(path)), ('Destination', path)],
warning='This replaces hosts.yml with its pre-change session copy. It does not restore host_vars, Vaults or remote system changes.')
if self.confirm('Restore this inventory backup?', default=False):
self.backups.restore(path, customer=customer)
self.ui.result('hosts.yml restored and locally YAML-validated. Run full validation next.', title='Recovery completed')
self.pause()
def config_menu(self) -> None:
draft = deepcopy(self.config)
while True:
self.clear()
dirty = draft != self.config
self.header('AIM > Settings', 'Unsaved changes' if dirty else str(self.config.config_path))
choice = self.menu([
MenuItem('1', 'Ansible root directory', str(draft.root_dir)),
MenuItem('2', 'Service account name', draft.service_user),
MenuItem('3', 'Authorized AIM group', draft.required_group),
MenuItem('4', 'Platform groups', ', '.join(draft.platform_groups)),
MenuItem('5', 'Save settings', 'Apply the pending values'),
MenuItem('6', 'Screen clearing', 'Enabled' if draft.ui_clear_screen else 'Disabled'),
MenuItem('7', 'Character style', 'ASCII' if draft.ui_ascii else 'Unicode (terminal-dependent)'),
MenuItem('8', 'Command output', draft.ui_output),
])
if choice == '0':
if not dirty or self.confirm('Discard unsaved settings and go back?', default=False):
return
continue
try:
if choice == '1':
value = Path(self.required_value('Ansible root directory', str(draft.root_dir))).expanduser()
if not value.is_absolute():
raise ValueError('Use an absolute Ansible root path.')
draft.root_dir = value
elif choice == '2':
draft.service_user = self.required_value('Service account name', draft.service_user)
elif choice == '3':
value = self.required_value('Authorized AIM group', draft.required_group)
require_group_membership(value)
draft.required_group = value
elif choice == '4':
raw = self.required_value('Platform groups (comma-separated)', ', '.join(draft.platform_groups))
groups = list(dict.fromkeys(i.strip() for i in raw.split(',') if i.strip()))
if not groups:
raise ValueError('At least one platform group is required.')
draft.platform_groups = groups
elif choice == '6':
draft.ui_clear_screen = not draft.ui_clear_screen
elif choice == '7':
draft.ui_ascii = not draft.ui_ascii
elif choice == '8':
draft.ui_output = 'live' if draft.ui_output == 'compact' else 'compact'
elif choice == '5':
self.ui.review('Save application settings', [('File', draft.config_path), ('Root directory', draft.root_dir),
('Service account', draft.service_user), ('Authorized group', draft.required_group)],
warning='Changing defaults does not migrate existing inventory variables, users or SSH keys.')
if self.confirm('Save these settings?', default=False):
draft.save()
for field in fields(draft):
setattr(self.config, field.name, deepcopy(getattr(draft, field.name)))
self.ui.clear_screen = self.config.ui_clear_screen
self.ui.ascii = self.config.ui_ascii or os.environ.get('TERM') == 'dumb'
self.command_ui.live_output = self.config.ui_output == 'live'
self._snapshot_key = None
self.ui.result('Settings saved.', title='Saved')
self.pause()
except (AIMError, OSError, ValueError, YAMLError) as exc:
self.show_error(exc)
self.pause()
+304
View File
@@ -0,0 +1,304 @@
"""Application composition and common operator-console behavior."""
from __future__ import annotations
from collections import Counter
import re
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.syntax import Syntax
from ruamel.yaml.error import YAMLError
from aim import __version__
from aim.auth import require_group_membership
from aim.backup.session import SessionBackupManager
from aim.config import Config
from aim.customers.manager import CustomerManager
from aim.exceptions import AIMError, OperationCancelled
from aim.external import external_presentation
from aim.inventory.hosts import InventoryEditor
from aim.inventory.loader import InventoryDocument
from aim.inventory.writer import InventoryWriter
from aim.playbooks.manager import PlaybookManager
from aim.ssh.keys import SSHManager
from aim.sophos.config import SophosConfigManager
from aim.variables.viewer import VariableViewer
from aim.vault.manager import VaultManager
from aim.winrm.manager import WinRMManager
from aim.ui.components import OperatorUI, MenuItem, text
from aim.ui.selection import Selector
from aim.ui.execution import CommandUI
from aim.ui.customers import CustomerScreens
from aim.ui.hosts import HostScreens
from aim.ui.access import AccessScreens
from aim.ui.playbooks import PlaybookScreens
from aim.ui.administration import AdministrationScreens
from aim.ui.targets import TargetScreens
_STYLE = re.compile(r'\[/?(?:bold|dim|red|green|yellow|cyan|magenta)(?: [^\]]+)?\]')
class App(CustomerScreens, HostScreens, AccessScreens, PlaybookScreens,
AdministrationScreens, TargetScreens):
def __init__(self, config: Config | None = None, *, console: Console | None = None,
no_clear: bool = False, plain: bool = False, live_output: bool = False):
self.config = config or Config.load()
self.ui = OperatorUI(console, ascii_only=plain or self.config.ui_ascii,
clear_screen=not no_clear and self.config.ui_clear_screen)
self.console = self.ui.console
self.customers = CustomerManager(self.config)
self.backups = SessionBackupManager()
self.writer = InventoryWriter(self.backups)
self.vault = VaultManager(self.customers)
self.variables_viewer = VariableViewer(self.customers)
self.ssh = SSHManager(self.customers, self.config, self.vault)
self.sophos = SophosConfigManager(self.customers)
self.winrm = WinRMManager(self.config)
self.playbooks = PlaybookManager(self.customers, self.config)
self.current_customer: str | None = None
self.current_section = 'Home'
self._snapshot_key = None
self._snapshot = None
self.command_ui = CommandUI(self.ui, live_output=live_output or self.config.ui_output == 'live')
self.selector = Selector(self.ui, self._selection_header)
def clear(self) -> None:
self.ui.clear()
@staticmethod
def breadcrumb(customer: str | None = None, section: str | None = None) -> str:
return ' > '.join(part for part in ['AIM', customer, section] if part)
def _selection_header(self, title: str) -> None:
self.header(self.breadcrumb(self.current_customer, title))
def snapshot(self, customer: str) -> dict:
"""Local metadata only. YAML OK does not mean Ansible/runtime validated."""
root = self.customers.path(customer)
inventory = root / 'hosts.yml'
key = None
try:
stat = inventory.stat()
key = (str(inventory), stat.st_mtime_ns, stat.st_size)
except OSError:
pass
if key is not None and key == self._snapshot_key:
data = dict(self._snapshot)
else:
data = {'inventory': 'Missing', 'hosts': None, 'counts': {}}
if key is not None:
try:
doc = InventoryDocument(inventory)
hosts = InventoryEditor(doc, customer).list_hosts()
data = {'inventory': 'YAML OK', 'hosts': len(hosts),
'counts': dict(Counter(g for h in hosts for g in h.memberships)),
'notes': doc.custom_structure_notes(customer)}
except (AIMError, ValueError, TypeError, YAMLError):
data['inventory'] = 'Invalid YAML / structure'
self._snapshot_key, self._snapshot = key, data
vault = root / 'group_vars/all/vault.yml'
try:
with vault.open('rb') as stream:
data['vault'] = 'Encrypted file present' if stream.read(14).startswith(b'$ANSIBLE_VAULT') else 'Unencrypted file present'
except FileNotFoundError:
data['vault'] = 'Not created'
except OSError:
data['vault'] = 'Not readable'
return data
def header(self, title: str, subtitle: str | None = None) -> None:
crumbs = [p.strip() for p in title.split(' > ') if p.strip()]
if not crumbs or crumbs[0] != 'AIM':
crumbs.insert(0, 'AIM')
# Navigation calls supply the exact customer context; never infer an AD domain.
if self.current_customer and self.current_customer not in crumbs:
crumbs.insert(1, self.current_customer)
self.current_section = crumbs[-1]
context = []
if self.current_customer:
data = self.snapshot(self.current_customer)
context = [('Customer', self.current_customer), ('Inventory', data['inventory']), ('Vault', data['vault'])]
self.ui.header(__version__, crumbs, subtitle or '', context)
def menu(self, items: list[MenuItem], *, back: str = 'Back') -> str:
self.ui.menu(items, back=back)
self.ui.footer('menu', exit_menu=back == 'Exit', back=back)
return self.menu_choice(['0'] + [item.key for item in items])
def menu_choice(self, choices: list[str]) -> str:
while True:
try:
choice = Prompt.ask('Choose', console=self.console, default='0', show_default=False).strip() or '0'
except KeyboardInterrupt:
return '0'
if choice in choices:
return choice
self.ui.result('Choose a listed number, or press Enter / 0 to go back.', level='warning')
def ask(self, prompt: str, **kwargs) -> str:
try:
# Hide default values for every password field, even when set by a caller.
if kwargs.get('password'):
kwargs['show_default'] = False
return Prompt.ask(text(prompt), console=self.console, **kwargs)
except KeyboardInterrupt:
raise OperationCancelled('Operation cancelled.') from None
def confirm(self, prompt: str, *, default: bool = False, **kwargs) -> bool:
try:
return Confirm.ask(text(prompt), console=self.console, default=default, **kwargs)
except KeyboardInterrupt:
raise OperationCancelled('Operation cancelled.') from None
def optional_value(self, prompt: str, current: str = '') -> str:
value = self.ask(prompt + ' (- to clear)', default=current, show_default=bool(current)).strip()
return '' if value == '-' else value
def required_value(self, prompt: str, default: str | None = None) -> str:
while True:
value = self.ask(prompt, default=default, show_default=bool(default)).strip()
if value:
return value
self.ui.result('A value is required. Ctrl+C cancels this operation.', level='warning')
def message(self, *objects, **kwargs) -> None:
"""Compatibility renderer for workflow text while keeping data literal."""
if len(objects) != 1 or not isinstance(objects[0], str):
self.console.print(*objects, **kwargs)
return
raw = objects[0]
clean = _STYLE.sub('', raw)
stripped = raw.lstrip()
level = ('success' if stripped.startswith('[green]') else 'warning' if stripped.startswith('[yellow]')
else 'error' if stripped.startswith('[red]') else None)
if level:
self.ui.result(clean.lstrip('\n'), level=level)
else:
self.console.print(text(clean), **kwargs)
def pause(self, message: str = 'Return to menu') -> None:
available = bool(self.command_ui.last_output)
self.ui.footer('result', extra='v View last command output' if available else '')
while True:
try:
answer = self.ask(message, default='', show_default=False).strip().lower()
except OperationCancelled:
return
if answer in {'', '0'}:
return
if answer == 'v' and available:
self.view_text('Last command output', self.command_ui.last_output, language='text')
else:
self.ui.result('Press Enter/0 to return' + (' or v to view output.' if available else '.'), level='info')
def view_text(self, title: str, content: str, *, language: str = 'yaml') -> None:
lines = content.splitlines() or ['']
page = 0
while True:
size = max(4, self.console.height - 13)
pages = max(1, (len(lines) + size - 1) // size)
page = min(page, pages - 1)
start = page * size
self.clear()
self.header(self.breadcrumb(self.current_customer, title), 'Read-only')
# Rich Syntax treats file contents as literal source, never console markup.
self.console.print(Syntax('\n'.join(lines[start:start+size]), language,
theme='ansi_dark', line_numbers=True, start_line=start+1,
word_wrap=True, background_color='default'))
self.console.print(text(f'Lines {start+1}-{min(start+size, len(lines))} of {len(lines)} | Page {page+1}/{pages}', 'aim.muted'))
self.ui.footer('viewer')
try:
raw = self.ask('Page', default='', show_default=False).strip().lower()
except OperationCancelled:
return
if raw in {'', '0'}:
return
if raw in {'n', 'p'}:
page = max(0, min(pages-1, page + (1 if raw == 'n' else -1)))
def show_error(self, exc: Exception) -> None:
if isinstance(exc, OperationCancelled):
self.ui.result('Operation cancelled. Completed steps are not rolled back.', level='warning')
return
cause = exc
seen = set()
while cause.__cause__ is not None and id(cause) not in seen:
seen.add(id(cause))
cause = cause.__cause__
if isinstance(cause, YAMLError):
mark = getattr(cause, 'problem_mark', None)
location = f'Line {mark.line + 1}, column {mark.column + 1}' if mark else 'Location unavailable'
name = getattr(mark, 'name', '')
safe_name = name if name and name not in {'<unicode string>', '<file>'} else 'YAML document'
# Parser text/snippets may contain secrets; do not render them.
detail = f'{safe_name}\n{location}: YAML could not be parsed.\nFile contents are not displayed. Correct the syntax and retry.'
else:
detail = str(exc)
self.ui.result(detail, level='error', title='Operation could not be completed')
def call(self, func, *args, **kwargs):
self.command_ui.last_output = ''
try:
return func(*args, **kwargs)
except (AIMError, OSError, ValueError, YAMLError) as exc:
self.show_error(exc)
self.pause()
except KeyboardInterrupt:
self.show_error(OperationCancelled())
self.pause()
def prompt_vault_template_values(self) -> dict[str, str]:
self.ui.result('Secret input is hidden. Enter leaves an optional value empty.', level='info')
fields = [
('vault_windows_ansible_password', 'Windows domain account password'),
('vault_windows_local_ansible_password', 'Windows shared local account password'),
('ansible_vault_linux_ssh_pass', 'Linux legacy account password'),
('vault_linux_ssh_key_passphrase', 'Linux private-key passphrase'),
]
return {key: self.ask(label, password=True, default='', show_default=False) for key, label in fields}
def run(self) -> int:
try:
auth = require_group_membership(self.config.required_group)
except AIMError as exc:
self.show_error(exc)
return 1
try:
with external_presentation(self.command_ui):
while True:
self.current_customer = None
self.clear()
self.header('AIM > Home', f'Authorized group: {auth.group_name} | GID {auth.gid}')
choice = self.menu([
MenuItem('1', 'Create customer', 'New inventory and optional defaults'),
MenuItem('2', 'Open customer', 'Hosts, access, Vault and playbooks'),
MenuItem('3', 'Browse customers', 'Local inventory overview'),
MenuItem('4', 'Delete customer', 'Remove the complete customer inventory', True),
MenuItem('5', 'Settings', 'Application and display preferences'),
], back='Exit')
if choice == '0':
return 0
if choice == '1':
self.call(self.create_customer)
elif choice == '2':
self.call(self.open_customer)
elif choice == '3':
self.call(self.list_customers_screen)
elif choice == '4':
self.call(self.delete_customer)
elif choice == '5':
self.call(self.config_menu)
except EOFError:
return 0
finally:
self.ssh._shutdown_agent()
self.command_ui.last_output = ''
def open_customer(self) -> None:
customer = self.pick_customer()
if customer:
self.current_customer = customer
try:
self.customer_menu(customer)
finally:
self.current_customer = None
+154
View File
@@ -0,0 +1,154 @@
"""Shared, side-effect-free presentation for AIM's operator console.
Rendering a screen never invokes Ansible, decrypts a Vault or probes a host.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Iterable, Sequence
from rich import box
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.theme import Theme
THEME = Theme({
'aim.title': 'bold #ff7a00', 'aim.context': '#ff7a00',
'aim.success': 'green', 'aim.warning': 'yellow', 'aim.error': 'bold red',
'aim.muted': 'dim', 'aim.vault': 'bold #ff7a00', 'aim.border': '#ff7a00',
})
_CONTROL = re.compile(r'\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))|[\x00-\x08\x0b-\x1f\x7f]')
def text(value: object, style: str = '') -> Text:
"""Never interpret names, paths or external errors as Rich markup/ANSI."""
return Text(_CONTROL.sub('', str(value)), style=style, overflow='fold')
@dataclass(frozen=True)
class MenuItem:
key: str
label: str
detail: str = ''
danger: bool = False
class OperatorUI:
def __init__(self, console: Console | None = None, *, ascii_only: bool = False,
clear_screen: bool = True):
self.console = console or Console(theme=THEME, highlight=False)
# Also install styles for a caller-supplied/testing Console.
self.console.push_theme(THEME)
self.ascii = ascii_only or os.environ.get('TERM') == 'dumb'
try:
'\u2713\u2717\u203a'.encode(self.console.encoding)
except (UnicodeEncodeError, LookupError):
self.ascii = True
self.clear_screen = clear_screen
@property
def border(self):
return box.ASCII if self.ascii else box.ROUNDED
def clear(self) -> None:
if self.clear_screen and self.console.is_terminal and not self.console.is_dumb_terminal:
self.console.clear()
def header(self, version: str, crumbs: Sequence[str], subtitle: str = '',
context: Sequence[tuple[str, str]] = ()) -> None:
brand = Table.grid(expand=True)
brand.add_column(ratio=1, vertical='middle')
brand.add_column(justify='right', vertical='middle')
identity = Text()
identity.append('bitformer', style='aim.title')
identity.append(' · ', style='aim.muted')
identity.append('AIM', style='aim.title')
identity.append(' · Ansible Inventory Manager')
brand.add_row(identity, text(f'v{version}', 'aim.muted'))
content: list = [brand]
if context:
row = Text()
for index, (key, value) in enumerate(context):
if index:
row.append(' | ', style='aim.muted')
row.append(f'{key}: ', style='aim.muted')
row.append(text(value, 'aim.vault' if key == 'Vault' else 'aim.context'))
content.append(row)
self.console.print(Panel(Group(*content), box=self.border, border_style='aim.border', padding=(0, 1)))
separator = ' > ' if self.ascii else ' \u203a '
self.console.print(text(separator.join(crumbs), 'bold'))
if subtitle:
self.console.print(text(subtitle, 'aim.muted'))
self.console.print()
def menu(self, items: Iterable[MenuItem], *, back: str = 'Back') -> None:
table = Table.grid(padding=(0, 2))
table.add_column(justify='right', width=3)
table.add_column()
table.add_column(style='aim.muted')
for item in items:
table.add_row(text(item.key, 'aim.context'),
text(item.label, 'red' if item.danger else ''), text(item.detail, 'aim.muted'))
table.add_row('', '', '')
table.add_row(text('0', 'aim.muted'), text(back, 'aim.muted'), '')
self.console.print(table)
def table(self, columns: Sequence[str], rows: Iterable[Sequence[object]], *, title: str | None = None) -> None:
table = Table(box=box.ASCII if self.ascii else box.SIMPLE_HEAD, expand=True,
show_edge=False, padding=(0, 1), header_style='bold', title=text(title) if title else None)
for i, column in enumerate(columns):
table.add_column(column, no_wrap=column in {'#', 'Selected'}, overflow='fold',
justify='right' if column == '#' else 'left')
for row in rows:
table.add_row(*(cell if isinstance(cell, Text) else text(cell) for cell in row))
self.console.print(table)
def details(self, rows: Iterable[tuple[str, object]], *, title: str | None = None) -> None:
table = Table.grid(padding=(0, 2), expand=True)
table.add_column(style='aim.muted', ratio=1, max_width=24)
table.add_column(ratio=3, overflow='fold')
for key, value in rows:
table.add_row(text(key, 'aim.muted'), text(value))
if title:
self.console.print(Panel(table, title=text(title), title_align='left',
box=self.border, border_style='aim.border'))
else:
self.console.print(table)
def result(self, message: str, *, level: str = 'success', title: str | None = None) -> None:
symbols = {'success': 'OK' if self.ascii else '\u2713', 'error': 'X' if self.ascii else '\u2717',
'warning': '!', 'info': 'i'}
style = {'success': 'aim.success', 'warning': 'aim.warning', 'error': 'aim.error', 'info': 'aim.context'}[level]
if title:
self.console.print(Panel(text(message), title=text(f'{symbols[level]} {title}', style),
title_align='left', border_style=style, box=self.border))
else:
line = text(f'{symbols[level]} ', style)
line.append(text(message))
self.console.print(line)
def review(self, title: str, rows: Iterable[tuple[str, object]], *, warning: str = '') -> None:
self.details(rows, title=title)
if warning:
self.result(warning, level='warning')
def footer(self, mode: str = 'menu', *, extra: str = '', exit_menu: bool = False, back: str = 'Back') -> None:
hints = {
'menu': 'Enter / 0 Back | Ctrl+C Back',
'select': 'Enter / 0 Cancel | /text Filter | p / n Page | Ctrl+C Cancel',
'multi': 'Number Toggle | Enter Review selection | 0 Cancel | /text Filter | p / n Page',
'form': 'Enter Accept shown default | Ctrl+C Cancel operation',
'review': 'y Confirm | Enter / n Cancel | Ctrl+C Cancel',
'result': 'Enter / 0 Return',
'viewer': 'Enter / 0 Back | p / n Page | Ctrl+C Back',
}
hint = 'Enter / 0 Exit | Ctrl+C Exit' if exit_menu else hints[mode]
if mode == 'menu' and not exit_menu and back != 'Back':
hint = f'Enter / 0 {back} | Ctrl+C Back'
self.console.print()
self.console.rule(style='aim.muted')
self.console.print(text(hint + (f' | {extra}' if extra else ''), 'aim.muted'))
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
import ipaddress
from ruamel.yaml.error import YAMLError
from aim.customers.manager import CustomerDefaults
from aim.exceptions import AIMError
from aim.ui.components import MenuItem
from aim.ui.selection import Choice
from aim.exceptions import OperationCancelled
class CustomerScreens:
def pick_customer(self) -> str | None:
choices = [Choice(name, (name, str(self.customers.path(name) / 'hosts.yml')), (name,))
for name in self.customers.list()]
return self.selector.select('Select customer', ['Customer', 'Inventory'], choices)
def list_customers_screen(self) -> None:
choices = []
for name in self.customers.list():
data = self.snapshot(name)
choices.append(Choice(name, (name, data['inventory'], str(data['hosts'] if data['hosts'] is not None else '-'), data['vault']), (name,)))
selected = self.selector.select('Browse customers', ['Customer', 'Inventory', 'Hosts', 'Vault'], choices)
if selected:
old = self.current_customer
self.current_customer = selected
try:
self.customer_menu(selected)
finally:
self.current_customer = old
def create_customer(self) -> None:
self.clear()
self.header('AIM > Create customer', 'Creates a new inventory. Existing customer directories are never replaced.')
name = self.ask('Customer name (Enter / 0 to cancel)', default='', show_default=False).strip()
if name in {'', '0'}:
return
self.customers._validate_name(name)
if self.customers.path(name).exists():
self.ui.result('This customer already exists. Open it to make changes; it was not overwritten.', level='warning')
self.pause()
return
self.ui.review('Create customer', [('Name', name), ('Inventory directory', self.customers.path(name))])
if not self.confirm('Create this customer?', default=False):
return
path = self.customers.create_structure(name)
self.current_customer = name
try:
self.ui.result(f'Customer created: {path}', title='Customer ready')
self.configure_customer_defaults(name, pause=False)
defaults = self.customers.load_defaults(name)
if defaults.ad_dns_domain:
service_upn = f'{self.config.service_user}@{defaults.ad_dns_domain.lower()}'
self.customers.set_windows_ansible_user(name, service_upn)
self.ui.result(f'New customer Windows default: {service_upn}', level='info')
if self.confirm('Create an encrypted Vault now?', default=False):
self.create_vault(name)
self.pause()
finally:
self.current_customer = None
def configure_customer_defaults(self, customer: str, *, pause: bool = True) -> None:
current = self.customers.load_defaults(customer)
self.ui.result('Enter keeps a displayed value. Use - to clear it. Existing hosts are not changed.', level='info')
domain = self.optional_value('Default hostname domain suffix', current.domain_suffix).lstrip('.')
network_address = self.optional_value('Default server network address', current.network_address)
netmask = ''
if network_address:
while True:
netmask = self.required_value('Network netmask', current.netmask or '255.255.255.0')
try:
network = ipaddress.IPv4Network((network_address, netmask), strict=False)
network_address, netmask = str(network.network_address), str(network.netmask)
break
except ValueError:
self.ui.result('Invalid IPv4 network/netmask. Re-enter the network; - disables it.', level='warning')
network_address = self.optional_value('Default server network address', network_address)
if not network_address:
netmask = ''
break
ad_dns_domain = self.optional_value('AD DNS domain', current.ad_dns_domain).lstrip('.')
ad_netbios_domain = self.optional_value('AD NetBIOS domain', current.ad_netbios_domain)
desired = CustomerDefaults(domain, network_address, netmask, ad_dns_domain, ad_netbios_domain)
if desired == current:
self.ui.result('Defaults are unchanged.', level='info')
else:
self.ui.review('Save customer defaults', [
('Hostname suffix', domain or '(not set)'), ('Server network', f'{network_address} / {netmask}' if network_address else '(not set)'),
('AD DNS domain', ad_dns_domain or '(not set)'), ('AD NetBIOS domain', ad_netbios_domain or '(not set)'),
], warning='These are suggestions for new operations. Existing hosts and credentials are not migrated.')
if self.confirm('Save these defaults?', default=False):
saved = self.customers.save_defaults(customer, desired)
self.ui.result(f'Defaults saved: {saved}', title='Saved')
if pause:
self.pause()
def _customer_network(self, customer: str) -> ipaddress.IPv4Network | None:
defaults = self.customers.load_defaults(customer)
if not defaults.network_address or not defaults.netmask:
return None
try:
return ipaddress.IPv4Network((defaults.network_address, defaults.netmask), strict=False)
except ValueError as exc:
raise ValueError(f"Invalid customer default network: {exc}") from exc
def prompt_host_fqdn(self, customer: str) -> str:
defaults = self.customers.load_defaults(customer)
hostname = self.ask('Hostname or FQDN (Enter / 0 to cancel)', default='', show_default=False).strip()
if hostname in {'', '0'}:
raise OperationCancelled('Host entry cancelled.')
if '.' in hostname:
return hostname.rstrip('.')
domain = self.optional_value('Domain suffix', defaults.domain_suffix).lstrip('.')
return f'{hostname}.{domain}' if domain else hostname
def prompt_host_ip(self, customer: str, *, existing_ip: str | None = None) -> str:
network = self._customer_network(customer)
if network is None:
if existing_ip:
return self.ask("Host IP", default=existing_ip).strip()
return self.ask("Host IP").strip()
default_value = None
if existing_ip:
try:
existing_addr = ipaddress.IPv4Address(existing_ip)
if existing_addr in network:
default_value = str(int(existing_addr) - int(network.network_address))
else:
default_value = existing_ip
except ValueError:
default_value = existing_ip
self.message(
f"[dim]Default network: {network.network_address} / {network.netmask} ({network.with_prefixlen})[/dim]"
)
raw = self.ask(
"Host number or full IP",
default=default_value,
show_default=default_value is not None,
).strip()
if not raw:
raise ValueError("Host address is required")
if "." in raw:
try:
return str(ipaddress.IPv4Address(raw))
except ValueError as exc:
raise ValueError(f"Invalid host IP: {raw}") from exc
try:
host_number = int(raw, 10)
except ValueError as exc:
raise ValueError("Enter a numeric host number or a full IPv4 address") from exc
if host_number < 0:
raise ValueError("Host number cannot be negative")
address_int = int(network.network_address) + host_number
if address_int > int(network.broadcast_address):
raise ValueError(f"Host number {host_number} is outside {network.with_prefixlen}")
address = ipaddress.IPv4Address(address_int)
if address not in network:
raise ValueError(f"Calculated address {address} is outside {network.with_prefixlen}")
if network.prefixlen <= 30 and address in (network.network_address, network.broadcast_address):
raise ValueError(f"{address} is not a usable host address in {network.with_prefixlen}")
return str(address)
def delete_customer(self) -> None:
self.clear()
self.header(self.breadcrumb(section="Delete Customer"))
customer = self.pick_customer()
if not customer:
return
path = self.customers.require(customer)
self.message(f"[red]This permanently deletes the customer directory:[/red]\n{path}")
answer = self.ask(f"Type '{customer}' to confirm", default="", show_default=False)
if answer != customer:
self.message("Cancelled. Nothing was changed.")
else:
self.customers.delete(customer)
self.message(f"[green]Deleted customer {customer}.[/green]")
self.pause()
def customer_menu(self, customer: str) -> None:
path = self.customers.require(customer) / 'hosts.yml'
while True:
self.clear()
self.header(self.breadcrumb(customer, 'Overview'))
data = self.snapshot(customer)
try:
defaults = self.customers.load_defaults(customer)
domain = defaults.ad_dns_domain or 'Not configured'
suffix = defaults.domain_suffix or 'Not configured'
except (AIMError, OSError, ValueError, YAMLError):
domain = suffix = 'Invalid customer defaults; review .aim.yml'
counts = data['counts']
if data.get('notes'):
self.ui.result('Custom inventory structure detected. AIM will preserve it; review it before structural changes.', level='info')
self.ui.details([
('Inventory', f"{data['inventory']} (local check only)"),
('Hosts', str(data['hosts']) if data['hosts'] is not None else 'Unavailable'),
('Platform memberships', ' | '.join(f'{g}: {counts.get(g, 0)}' for g in self.config.platform_groups)),
('AD domain', domain), ('Hostname suffix', suffix),
], title='Customer overview')
if data['inventory'] != 'YAML OK':
self.ui.result('Inventory cannot be parsed. Administration and file viewing remain available.', level='warning')
choice = self.menu([
MenuItem('1', 'Hosts', 'Add, inspect or remove inventory hosts'),
MenuItem('2', 'Access', 'SSH and WinRM setup / connection tests'),
MenuItem('3', 'Vault', 'Create, edit or inspect Vault status'),
MenuItem('4', 'Group variables', 'Read-only YAML viewer'),
MenuItem('5', 'Host variables', 'Read-only YAML viewer'),
MenuItem('6', 'Playbooks', 'Select targets and run approved playbooks'),
MenuItem('7', 'Administration', 'Validate, consolidate, recover and set defaults'),
])
if choice == '0':
return
actions = {
'1': (self.hosts_menu, (customer, path)), '2': (self.access_menu, (customer, path)),
'3': (self.vault_menu, (customer,)), '4': (self.variables_menu, (customer, 'group_vars', 'Group variables')),
'5': (self.variables_menu, (customer, 'host_vars', 'Host variables')),
'6': (self.playbooks_menu, (customer, path)), '7': (self.administration_menu, (customer, path)),
}
func, args = actions[choice]
self.call(func, *args)
+114
View File
@@ -0,0 +1,114 @@
"""Compact output for known noninteractive jobs; native terminal for everything else.
Never parse callback output to determine command success or application data.
Exit codes and the managers' structured result files remain authoritative.
"""
from __future__ import annotations
from collections import deque
from contextlib import nullcontext
import re
import subprocess
from pathlib import Path
from aim.external import execute, stop_process
from aim.ui.components import text
_ANSI = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]')
_SECRET = re.compile(r'(?i)(?:password|passphrase|secret|token|private[_ -]?key)\s*["\']?\s*[:=]')
def safe_output(output: str) -> str:
"""Defensive display filter, in addition to Ansible's no_log handling.
Vault plaintext is never sent through this component at all. It is not a
replacement for no_log on playbooks which intentionally print credentials.
"""
cleaned = _ANSI.sub('', output)
result = []
private = False
for line in cleaned.splitlines():
if '-----BEGIN ' in line and 'PRIVATE KEY-----' in line:
private = True
result.append('[private key omitted]')
if private:
if '-----END ' in line and 'PRIVATE KEY-----' in line:
private = False
continue
result.append('[line with credential field omitted]' if _SECRET.search(line) else line)
return '\n'.join(result)
class CommandUI:
def __init__(self, ui, *, live_output: bool = False):
self.ui = ui
self.live_output = live_output
self.last_output = ''
def __call__(self, args, kwargs, *, capture_output, suppress_stdout, ui_mode):
command = Path(args[0]).name
interactive = command in {'ansible-vault', 'ssh', 'ssh-keygen', 'ssh-copy-id'} or any(
item in {'--ask-pass', '-k', '--ask-become-pass', '-K', '--ask-vault-pass'} or '@prompt' in item
for item in args
)
# A curated customer playbook may itself contain pause/interactive tasks.
# Only callers that own all tasks can opt into compact capture.
compact = (ui_mode == 'compact' and not interactive and not capture_output
and not suppress_stdout and kwargs.get('text') and not self.live_output)
if compact:
return self._compact(args, kwargs)
if not capture_output and not suppress_stdout:
self.ui.console.print(text(f'Running {command} | live terminal output', 'aim.muted'))
self.ui.console.print(text('Ctrl+C interrupts the command. Remote changes already made are not rolled back.', 'aim.muted'))
animated = (capture_output and not interactive and self.ui.console.is_terminal)
status = self.ui.console.status(text(f'Running {command}...', 'aim.context'),
spinner='line' if self.ui.ascii else 'dots') if animated else nullcontext()
with status:
return execute(args, kwargs)
def _compact(self, args, kwargs):
options = dict(kwargs)
options.update(stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
command = Path(args[0]).name
tail: deque[str] = deque(maxlen=500)
last_task = f'Running {command}'
recap = []
private_key_block = False
self.last_output = ''
process = subprocess.Popen(args, **options)
status = self.ui.console.status(text(last_task, 'aim.context'), spinner='line' if self.ui.ascii else 'dots')
context = status if self.ui.console.is_terminal else nullcontext()
try:
with context:
assert process.stdout is not None
for line in process.stdout:
# Keep only a bounded, filtered, in-memory diagnostic tail.
raw = _ANSI.sub('', line.rstrip())
if '-----BEGIN ' in raw and 'PRIVATE KEY-----' in raw:
private_key_block = True
tail.append('[private key omitted]')
if private_key_block:
if '-----END ' in raw and 'PRIVATE KEY-----' in raw:
private_key_block = False
continue
safe = safe_output(raw)[:2000]
tail.append(safe)
if safe.startswith(('TASK [', 'PLAY [')):
last_task = safe.rstrip(' *')
if self.ui.console.is_terminal:
status.update(text(last_task, 'aim.context'))
if re.search(r'\bok=\d+.*\bfailed=\d+', safe):
recap.append(safe)
code = process.wait()
self.last_output = '\n'.join(tail)
if recap:
self.ui.details([(str(i+1), line) for i, line in enumerate(recap)], title='Ansible recap')
if code:
self.ui.result(f'{command} exited with code {code}. Use v on the result screen for command output.', level='error')
else:
self.ui.result(f'{command} finished successfully.', level='success')
return subprocess.CompletedProcess(args, code)
finally:
stop_process(process, group=True)
if process.stdout is not None:
process.stdout.close()
+306
View File
@@ -0,0 +1,306 @@
from __future__ import annotations
from pathlib import Path
from aim.inventory.hosts import InventoryEditor
from aim.inventory.loader import InventoryDocument
from aim.inventory.model import ChangeSet, Host
from aim.ui.components import MenuItem
class HostScreens:
def hosts_menu(self, customer: str, path: Path) -> None:
while True:
self.clear()
self.header(self.breadcrumb(customer, 'Hosts'))
choice = self.menu([
MenuItem('1', 'Add / update host', 'Preview changes before saving'),
MenuItem('2', 'Browse hosts', 'Addresses and group memberships'),
MenuItem('3', 'View hosts.yml', 'Read-only, with line numbers'),
MenuItem('4', 'Remove host', 'Also removes its host_vars directory', True),
])
if choice == '0':
return
action = {'1': self.upsert_host, '2': self.list_hosts, '3': self.view_inventory, '4': self.remove_host}[choice]
self.call(action, customer, path)
def prompt_memberships(self) -> dict[str, set[str]]:
self.message("\n[bold]Group memberships[/bold]")
self.message("Enter one group at a time; empty group input finishes. At least one group is required.")
self.message(f"[dim]Default main groups: {', '.join(self.config.platform_groups)}[/dim]")
result: dict[str, set[str]] = {}
while True:
group = self.ask("Group (empty to finish)", default="", show_default=False).strip()
if not group:
if result:
return result
self.message("[yellow]At least one group is required.[/yellow]")
continue
if "/" in group:
self.message("[yellow]Enter the group and subgroup separately.[/yellow]")
continue
subs: set[str] = set()
while True:
subgroup = self.ask(f"Subgroup under {group} (empty to finish this group)", default="", show_default=False).strip()
if not subgroup:
break
if "/" in subgroup:
self.message("[yellow]AIM-created subgroups are one level deep.[/yellow]")
continue
subs.add(subgroup)
result.setdefault(group, set()).update(subs)
@staticmethod
def host_from_memberships(fqdn: str, ip: str, memberships: dict[str, set[str]]) -> Host:
host = Host(fqdn=fqdn, ansible_host=ip)
for group, subgroups in memberships.items():
if subgroups:
for subgroup in sorted(subgroups):
host.add_subgroup(group, subgroup)
else:
host.add_group(group)
return host
def find_host(self, customer: str, path: Path, fqdn: str) -> Host | None:
editor = InventoryEditor(InventoryDocument(path), customer)
return next((host for host in editor.list_hosts() if host.fqdn == fqdn), None)
def print_host_state(self, host: Host) -> None:
self.ui.details([('Host', host.fqdn), ('Address', host.ansible_host or '(not set)'),
('Memberships', '\n'.join(host.membership_labels()) or '(none)')], title='Host details')
def print_changes(self, changes: ChangeSet) -> None:
rows = []
rows.extend(('Update', v) for v in changes.changed)
rows.extend(('Add', v) for v in changes.added)
rows.extend(('Remove', v) for v in changes.removed)
if rows:
self.ui.table(['Planned change', 'Details'], rows)
for item in changes.warnings:
self.ui.result(item, level='warning')
def upsert_host(self, customer: str, path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, "Add or Update Host"))
fqdn = self.prompt_host_fqdn(customer)
existing = self.find_host(customer, path, fqdn)
was_new = existing is None
if existing:
self.print_host_state(existing)
if existing.ansible_host == "CONFLICT":
raise ValueError("Host has conflicting ansible_host values; resolve them manually before AIM can update it")
ip = self.prompt_host_ip(customer, existing_ip=existing.ansible_host)
if self.confirm("Keep current AIM-manageable memberships?", default=True):
memberships = {}
for group, membership in existing.memberships.items():
simple_subgroups = {sub for sub in membership.subgroups if "/" not in sub}
if simple_subgroups:
memberships[group] = simple_subgroups
elif not membership.subgroups:
memberships[group] = set()
if not memberships:
self.message(
"[yellow]This host currently has no one-level AIM-manageable membership. "
"Its custom memberships will be preserved; choose at least one AIM membership.[/yellow]"
)
memberships = self.prompt_memberships()
else:
memberships = self.prompt_memberships()
else:
ip = self.prompt_host_ip(customer)
memberships = self.prompt_memberships()
document = InventoryDocument(path)
editor = InventoryEditor(document, customer)
desired = self.host_from_memberships(fqdn, ip, memberships)
changes = editor.upsert_host(desired)
if not changes.changed_anything and not changes.changed:
# May still need to ensure the default host_vars file.
if "sophosxgs" in desired.memberships:
self.customers.ensure_sophos_host_vars(customer, fqdn)
else:
self.customers.ensure_host_vars(customer, fqdn)
self.message("\n[green]Host already matches the requested state. No inventory changes required.[/green]")
self.pause()
return
self.ui.review('Save host', [('Host', fqdn), ('Address', ip), ('Memberships', ', '.join(desired.membership_labels()))])
self.print_changes(changes)
if not self.confirm("Apply these changes?", default=False):
self.message("Cancelled. Nothing was changed.")
self.pause()
return
changed = self.writer.commit(document, customer=customer)
if "sophosxgs" in desired.memberships:
host_vars_path = self.customers.ensure_sophos_host_vars(customer, fqdn)
else:
host_vars_path = self.customers.ensure_host_vars(customer, fqdn)
self.message("[green]Inventory updated and YAML-validated.[/green]" if changed else "No inventory changes required.")
if was_new:
self.message(f"\n[yellow]Host variables:[/yellow] {host_vars_path}")
if "sophosxgs" in desired.memberships:
self.message("A Sophos XGS network-object/VLAN template was created for this host.")
else:
self.message(
"Use this file for host-specific variables such as a local Windows "
"ansible_password override when the domain credential does not apply."
)
if "windows" in desired.memberships:
self.message("\n[bold]Windows credential model[/bold]")
model_choice = self.menu([
MenuItem('1', 'Domain service account', 'Use Windows group defaults'),
MenuItem('2', 'Shared local service account', 'Use shared-local Vault password'),
MenuItem('3', 'Host-specific local account', 'Prepare a host-specific Vault reference'),
], back='Keep existing configuration')
if model_choice == '0':
self.pause()
return
model = {"1": "domain", "2": "shared_local", "3": "host_local"}[model_choice]
host_vars_path = self.customers.set_windows_host_credential_model(customer, fqdn, model)
if model == "host_local":
import re as _re
key = _re.sub(r"[^a-z0-9]+", "_", fqdn.lower()).strip("_")
self.message(f"Vault variable to add: vault_ansible_password_{key}")
elif model == "shared_local":
self.message("Uses Vault variable: vault_windows_local_ansible_password")
self.pause()
def list_hosts(self, customer: str, path: Path) -> None:
while True:
hosts = self.inventory_hosts(customer, path)
host = self.pick_one_host(hosts, 'Hosts > Browse')
if host is None:
return
self.clear()
self.header(self.breadcrumb(customer, 'Hosts > Details'))
self.print_host_state(host)
self.ui.details([('Host variables', self.customers.path(customer) / 'host_vars' / host.fqdn / 'main.yml')])
self.pause()
def pick_host(self, customer: str, path: Path, *, linux_only=False, windows_only=False, sophos_only=False) -> Host | None:
platforms = ('linux',) if linux_only else ('windows',) if windows_only else ('sophosxgs',) if sophos_only else ()
return self.pick_one_host(self.inventory_hosts(customer, path, platforms))
def configure_sophos(self, customer: str, path: Path) -> None:
self.clear()
self.header(self.breadcrumb(customer, "Configure Sophos XGS"))
host = self.pick_host(customer, path, sophos_only=True)
if not host:
return
hostname = host.fqdn.split(".", 1)[0]
existing_network, existing_vlans = self.sophos.existing_keys(customer, host.fqdn)
network_objects: dict[str, dict[str, object]] = {}
vlan_interfaces: dict[str, dict[str, object]] = {}
self.message("\n[bold]Network objects[/bold]")
self.message("[dim]Enter an object key; empty input finishes this section.[/dim]")
while True:
key = self.ask("Object key (empty to finish)", default="", show_default=False).strip()
if not key:
break
if (key in existing_network or key in network_objects) and not self.confirm(f"{key} already exists. Replace it?", default=False):
continue
default_name = self.sophos.default_network_object_name(customer, key)
name = self.ask("Name", default=default_name).strip()
network = self.ask("Network address").strip()
subnetmask = self.ask("Subnet mask", default="255.255.255.0").strip()
network_objects[key] = {
"name": name,
"network": network,
"subnetmask": subnetmask,
}
self.message("\n[bold]VLAN interfaces[/bold]")
self.message("[dim]Enter a VLAN key; empty input finishes this section.[/dim]")
while True:
key = self.ask("VLAN key (empty to finish)", default="", show_default=False).strip()
if not key:
break
if (key in existing_vlans or key in vlan_interfaces) and not self.confirm(f"{key} already exists. Replace it?", default=False):
continue
name = self.ask("Name", default=key).strip()
ip_address = self.ask("Interface IP").strip()
subnetmask = self.ask("Subnet mask", default="255.255.255.0").strip()
while True:
raw_vlan = self.ask("VLAN ID").strip()
try:
vlan_id = int(raw_vlan)
except ValueError:
self.message("[yellow]VLAN ID must be a number from 1 to 4094.[/yellow]")
continue
if 1 <= vlan_id <= 4094:
break
self.message("[yellow]VLAN ID must be a number from 1 to 4094.[/yellow]")
zone_name = self.ask("Zone name", default=name).strip()
zone_type = self.ask("Zone type", default="LAN").strip()
zone_description = self.ask(
"Zone description", default=f"{zone_name} Zone ({zone_type})"
).strip()
vlan_interfaces[key] = {
"name": name,
"ip_address": ip_address,
"subnetmask": subnetmask,
"vlan_id": vlan_id,
"zone_name": zone_name,
"zone_type": zone_type,
"zone_description": zone_description,
}
if not network_objects and not vlan_interfaces:
self.message("No Sophos configuration entries supplied. Nothing changed.")
self.pause()
return
self.message(f"\n[bold]Host:[/bold] {host.fqdn}")
self.message(f"Network objects: {', '.join(network_objects) if network_objects else '-'}")
self.message(f"VLAN interfaces: {', '.join(vlan_interfaces) if vlan_interfaces else '-'}")
if not self.confirm("Write these entries to host_vars?", default=False):
self.message("Cancelled. Nothing was changed.")
self.pause()
return
host_vars = self.sophos.apply(
customer,
host.fqdn,
hostname=hostname,
network_objects=network_objects,
vlan_interfaces=vlan_interfaces,
)
self.message(f"[green]Sophos XGS host configuration updated.[/green]\n{host_vars}")
self.pause()
def view_inventory(self, customer: str, path: Path) -> None:
self.view_text('Hosts > hosts.yml', path.read_text(encoding='utf-8'))
def remove_host(self, customer: str, path: Path) -> None:
host = self.pick_host(customer, path)
if not host:
return
self.ui.review('Remove host', [('Host', host.fqdn), ('Inventory', path),
('Also delete', path.parent / 'host_vars' / host.fqdn)],
warning='The hosts.yml recovery backup does not restore deleted host_vars. Save any needed host-specific files separately.')
if not self.confirm('Remove this host and its host variables?', default=False):
return
document = InventoryDocument(path)
changes = InventoryEditor(document, customer).remove_host(host.fqdn)
self.writer.commit(document, customer=customer)
removed_vars = self.customers.remove_host_vars(customer, host.fqdn)
self.ui.result('Host removed; inventory YAML validation passed.', title='Host removed')
if removed_vars:
self.ui.result('The related host_vars directory was also removed.', level='info')
self.pause()
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from pathlib import Path
from aim.inventory.hosts import InventoryEditor
from aim.inventory.loader import InventoryDocument
from aim.inventory.model import Host
from aim.playbooks.manager import PlaybookSpec
from aim.ui.components import MenuItem, text
from aim.ui.selection import Choice
class PlaybookScreens:
def playbooks_menu(self, customer: str, inventory_path: Path) -> None:
categories = self.playbooks.categories()
while True:
self.clear()
self.header(self.breadcrumb(customer, 'Playbooks'), f'Playbook directory: {self.playbooks.root}')
items = []
for i, category in enumerate(categories, 1):
specs = self.playbooks.specs_for_category(category)
count = sum(self.playbooks.available(s, customer) for s in specs)
items.append(MenuItem(str(i), category, f'{count}/{len(specs)} playbooks available'))
choice = self.menu(items)
if choice == '0':
return
self.call(self.playbook_category_menu, customer, inventory_path, categories[int(choice)-1])
def playbook_category_menu(self, customer: str, inventory_path: Path, category: str) -> None:
specs = self.playbooks.specs_for_category(category)
while True:
self.clear()
self.header(self.breadcrumb(customer, f'Playbooks > {category}'))
offset = 2 if category == 'Sophos XGS' else 1
rows = []
if offset == 2:
rows.append(('1', 'Prepare host configuration', 'Host variables', 'Available'))
for i, spec in enumerate(specs, offset):
state = text('Available', 'aim.success') if self.playbooks.available(spec, customer) else text('File missing', 'aim.warning')
rows.append((str(i), spec.name, ', '.join(spec.platforms), state))
self.ui.table(['#', 'Action', 'Scope / type', 'Status'], rows)
if category == 'Sophos XGS':
self.ui.result('Prepare variables, run the initial baseline, then apply the customer override. Playbooks are not chained automatically.', level='info')
self.message('0 Back')
self.ui.footer('menu')
raw = self.menu_choice([str(i) for i in range(len(specs) + offset)])
if raw == '0':
return
if offset == 2 and raw == '1':
self.call(self.configure_sophos, customer, inventory_path)
else:
self.call(self.run_playbook_flow, customer, inventory_path, specs[int(raw)-offset])
def compatible_hosts(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> list[Host]:
hosts = InventoryEditor(InventoryDocument(inventory_path), customer).list_hosts()
allowed = set(spec.platforms)
return [host for host in hosts if allowed.intersection(host.memberships)]
def compatible_group_limits(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> list[tuple[str, str]]:
hosts = self.compatible_hosts(customer, inventory_path, spec)
allowed = set(spec.platforms)
labels: dict[str, str] = {}
for host in hosts:
for group, membership in host.memberships.items():
if group not in allowed:
continue
labels[group] = group
for subgroup in membership.subgroups:
if "/" in subgroup:
continue
labels[f"{group} / {subgroup}"] = subgroup
return sorted(labels.items(), key=lambda item: item[0].lower())
def _pick_playbook_host(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> Host | None:
return self.pick_one_host(self.compatible_hosts(customer, inventory_path, spec))
def _pick_playbook_group(self, customer: str, inventory_path: Path, spec: PlaybookSpec):
groups = self.compatible_group_limits(customer, inventory_path, spec)
return self.selector.select('Select group', ['Group'], [Choice(g, (g[0],), (g[0],)) for g in groups])
def run_playbook_flow(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> None:
playbook_path = self.playbooks.path(spec, customer)
if not playbook_path.is_file():
self.ui.result(f'This playbook is not installed: {playbook_path}', level='warning', title='Playbook unavailable')
self.pause()
return
targets, label = self.select_targets(customer, inventory_path, spec.platforms, title=f'Playbooks > {spec.category} > {spec.name}')
if not targets:
return
limit = ':'.join(host.fqdn for host in targets)
self.clear()
self.header(self.breadcrumb(customer, f'Playbooks > {spec.category} > Review'))
vault = inventory_path.parent / 'group_vars/all/vault.yml'
rows = [('Playbook', spec.name), ('File', playbook_path), ('Inventory', inventory_path),
('Selection', label), ('Targets', '\n'.join(h.fqdn for h in targets)),
('Vault', f'{customer}@prompt' if vault.is_file() else 'Not present'),
('Connection password', 'Prompted by Ansible (--ask-pass)' if spec.ask_pass else 'Inventory / Vault')]
if spec.extra_vars:
rows.append(('Extra variables', ', '.join(spec.extra_vars)))
self.ui.review('Run playbook', rows, warning='The playbook may change the selected hosts. No other hosts are included in --limit.')
self.ui.footer('review')
if not self.confirm('Run this playbook?', default=False):
return
# Do not unlock a Linux key for a Windows-only target selection.
if any('linux' in h.memberships for h in targets) and self.ssh.status(customer).complete and vault.is_file():
if self.ssh.ensure_key_loaded(customer):
self.ui.result('Customer SSH key loaded into ssh-agent.')
self.ui.result('Playbook output remains live so native password prompts, pauses and diagnostics remain usable.', level='info')
self.playbooks.run(customer, inventory_path, spec, limit=limit)
self.ui.result(f'{spec.name} completed for the selected scope.', title='Playbook finished')
self.pause()
+87
View File
@@ -0,0 +1,87 @@
"""One selection model for customers, hosts, groups, files and AD OUs."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Generic, Sequence, TypeVar
from rich.prompt import Prompt
from aim.exceptions import OperationCancelled
from aim.ui.components import OperatorUI, text
T = TypeVar('T')
@dataclass(frozen=True)
class Choice(Generic[T]):
value: T
cells: tuple[str, ...]
aliases: tuple[str, ...] = ()
class Selector:
def __init__(self, ui: OperatorUI, draw_header):
self.ui = ui
self.draw_header = draw_header
def select(self, title: str, columns: Sequence[str], choices: Sequence[Choice[T]],
*, multiple: bool = False) -> list[T] | T | None:
if not choices:
self.ui.result('No matching items. Nothing was selected.', level='info')
return [] if multiple else None
selected: set[int] = set()
page = 0
query = ''
feedback = ''
while True:
indices = [i for i, choice in enumerate(choices) if query.casefold() in ' '.join(choice.cells).casefold()]
# Keep stable indices even across filtering and pages.
page_size = max(3, min(12, (self.ui.console.height - 14) // 2))
pages = max(1, (len(indices) + page_size - 1) // page_size)
page = min(page, pages - 1)
visible = indices[page * page_size:(page + 1) * page_size]
self.ui.clear()
self.draw_header(title)
rows = []
for i in visible:
mark = text('[x]' if self.ui.ascii else '[\u2713]', 'aim.success') if i in selected else text('[ ]', 'aim.muted')
row = [mark, str(i + 1), *choices[i].cells] if multiple else [str(i + 1), *choices[i].cells]
rows.append(row)
self.ui.table((['Selected', '#'] if multiple else ['#']) + list(columns), rows)
status = f'Page {page + 1}/{pages} | {len(indices)} matching / {len(choices)} total'
if multiple:
status += f' | Selected: {len(selected)}'
if query:
status += f' | Filter: {query}'
self.ui.console.print(text(status, 'aim.muted'))
if feedback:
self.ui.result(feedback, level='warning')
self.ui.footer('multi' if multiple else 'select', extra='/' + ' Clear filter' if query else '')
try:
raw = Prompt.ask('Select', console=self.ui.console, default='', show_default=False).strip()
except KeyboardInterrupt:
raise OperationCancelled('Selection cancelled.') from None
feedback = ''
if raw == '0':
return [] if multiple else None
if not raw:
if not multiple:
return None
return [choices[i].value for i in sorted(selected)]
if raw.startswith('/'):
query, page = raw[1:].strip(), 0
continue
if raw.casefold() in {'n', 'p'}:
page = max(0, min(pages - 1, page + (1 if raw.casefold() == 'n' else -1)))
continue
index = int(raw) - 1 if raw.isdigit() else -1
if not raw.isdigit():
matches = [i for i, choice in enumerate(choices) if raw in choice.aliases]
if len(matches) == 1:
index = matches[0]
if index not in indices:
feedback = 'Use a listed number, an exact name, or /text to filter.'
continue
if multiple:
selected.symmetric_difference_update({index})
else:
return choices[index].value
+79
View File
@@ -0,0 +1,79 @@
"""Reusable, explicit target selection; group paths only build candidate sets."""
from __future__ import annotations
from pathlib import Path
from aim.inventory.hosts import InventoryEditor
from aim.inventory.loader import InventoryDocument
from aim.inventory.model import Host
from aim.ui.components import MenuItem
from aim.ui.selection import Choice
class TargetScreens:
def inventory_hosts(self, customer: str, path: Path, platforms=(), *, require_address=False) -> list[Host]:
hosts = InventoryEditor(InventoryDocument(path), customer).list_hosts()
hosts = sorted(hosts, key=lambda h: h.fqdn.casefold())
if platforms:
hosts = [h for h in hosts if set(platforms).intersection(h.memberships)]
if require_address:
hosts = [h for h in hosts if h.ansible_host and h.ansible_host != 'CONFLICT']
return hosts
@staticmethod
def host_choices(hosts: list[Host]):
return [Choice(h, (h.fqdn, h.ansible_host or '-', ', '.join(h.membership_labels()) or '-'), (h.fqdn,)) for h in hosts]
def pick_multiple_hosts(self, hosts: list[Host], title: str = 'Select hosts') -> list[Host]:
chosen = self.selector.select(title, ['Host', 'Address', 'Memberships'], self.host_choices(hosts), multiple=True)
if not chosen:
return []
self.ui.review('Review selection', [('Hosts', '\n'.join(h.fqdn for h in chosen)), ('Count', len(chosen))])
return chosen if self.confirm('Use this selection?', default=False) else []
def pick_one_host(self, hosts: list[Host], title: str = 'Select host') -> Host | None:
return self.selector.select(title, ['Host', 'Address', 'Memberships'], self.host_choices(hosts))
def select_targets(self, customer: str, path: Path, platforms=(), *, title='Select targets', require_address=False):
candidates = self.inventory_hosts(customer, path, platforms, require_address=require_address)
if not candidates:
self.ui.result('No compatible hosts with usable inventory data.', level='warning')
return [], ''
while True:
self.clear()
self.header(self.breadcrumb(customer, title), 'Platforms: ' + (', '.join(platforms) or 'all'))
scope = self.menu([
MenuItem('1', 'All compatible hosts', f'{len(candidates)} hosts; reviewed before execution'),
MenuItem('2', 'From a group or subgroup', 'Filter first, then select hosts'),
MenuItem('3', 'Select multiple hosts', 'Toggle one number at a time'),
MenuItem('4', 'Select one host', 'Single target'),
], back='Cancel')
if scope == '0':
return [], ''
if scope == '1':
return candidates, f'All compatible hosts ({len(candidates)})'
if scope == '3':
chosen = self.pick_multiple_hosts(candidates, title)
return chosen, f'{len(chosen)} selected hosts'
if scope == '4':
chosen = self.pick_one_host(candidates, title)
return ([chosen], chosen.fqdn) if chosen else ([], '')
groups = {}
for h in candidates:
for platform, membership in h.memberships.items():
if platforms and platform not in platforms:
continue
groups.setdefault((platform, ''), []).append(h)
for sub in membership.subgroups:
groups.setdefault((platform, sub), []).append(h)
group_choices = [Choice(pair, (f'{pair[0]} / {pair[1]}' if pair[1] else pair[0], str(len(hosts))))
for pair, hosts in sorted(groups.items())]
pair = self.selector.select('Select group or subgroup', ['Group path', 'Hosts'], group_choices)
if pair is None:
continue
label = f'{pair[0]} / {pair[1]}' if pair[1] else pair[0]
chosen = self.pick_multiple_hosts(groups[pair], f'Select hosts from {label}')
return chosen, f'{label} ({len(chosen)} selected)'
def pick_windows_member_targets(self, customer: str, inventory_path: Path):
return self.select_targets(customer, inventory_path, ('windows',),
title='Enable member-server access', require_address=True)
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from pathlib import Path
class VariableViewer:
def __init__(self, customers):
self.customers = customers
def files(self, customer: str, kind: str) -> list[Path]:
if kind not in {"group_vars", "host_vars"}:
raise ValueError("Unsupported variable directory")
root = self.customers.require(customer) / kind
if not root.exists():
return []
return sorted(p for p in root.rglob("*.yml") if p.is_file())
def relative(self, customer: str, path: Path) -> Path:
return path.relative_to(self.customers.require(customer))
View File
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
from dataclasses import dataclass
import os
import subprocess
from pathlib import Path
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
from aim.exceptions import ExternalCommandFailed
from aim.external import command_available, run_external, stable_cwd
from aim.permissions import apply_managed_permissions
from aim.templates import VAULT_TEMPLATE_SECTIONS
from aim.structure import vault_structure_from_text
@dataclass(frozen=True)
class VaultStatus:
path: Path
exists: bool
class VaultManager:
def __init__(self, customers):
self.customers = customers
def path(self, customer: str) -> Path:
return self.customers.require(customer) / "group_vars/all/vault.yml"
def status(self, customer: str) -> VaultStatus:
path = self.path(customer)
return VaultStatus(path, path.is_file())
def _require_command(self) -> None:
if not command_available("ansible-vault"):
raise ExternalCommandFailed("Required command not found: ansible-vault")
@staticmethod
def template_data(values: dict[str, str] | None = None) -> dict[str, str]:
values = values or {}
# Secrets are always emitted as quoted YAML strings. This prevents values
# such as true, null, numeric-only passwords, leading # characters, or
# values containing YAML punctuation from changing type or syntax.
return {
"vault_windows_ansible_password": DoubleQuotedScalarString(values.get("vault_windows_ansible_password", "")),
"vault_windows_local_ansible_password": DoubleQuotedScalarString(values.get("vault_windows_local_ansible_password", "")),
"ansible_vault_linux_ssh_pass": DoubleQuotedScalarString(values.get("ansible_vault_linux_ssh_pass", "")),
"vault_linux_ssh_key_passphrase": DoubleQuotedScalarString(values.get("vault_linux_ssh_key_passphrase", "")),
}
def create(self, customer: str, values: dict[str, str] | None = None) -> bool:
import tempfile
path = self.path(customer)
if path.exists():
return False
self._require_command()
path.parent.mkdir(parents=True, exist_ok=True)
yaml = YAML()
data = self.template_data(values)
with tempfile.TemporaryDirectory(prefix=".aim-vault-", dir=path.parent) as directory:
tmp = Path(directory) / "vault.yml"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
for comment, key in VAULT_TEMPLATE_SECTIONS:
stream.write(comment + "\n")
if key:
yaml.dump({key: data[key]}, stream)
stream.write("\n")
with tmp.open(encoding="utf-8") as stream:
if not isinstance(YAML(typ="safe").load(stream), dict):
raise ValueError("Vault template must be a YAML mapping")
result = run_external(["ansible-vault", "encrypt", "--vault-id", f"{customer}@prompt", str(tmp)],
cwd=self.customers.require(customer))
if result.returncode or not tmp.read_bytes().startswith(b"$ANSIBLE_VAULT;"):
raise ExternalCommandFailed("Vault encryption failed; no plaintext Vault was published")
apply_managed_permissions(tmp, self.customers.config.required_group)
try:
# Link, rather than replace, makes concurrent creation non-destructive.
os.link(tmp, path)
except FileExistsError:
return False
return True
def view_data(self, customer: str) -> dict:
path = self.path(customer)
if not path.exists():
raise FileNotFoundError(path)
self._require_command()
text = self.view_text(customer)
data = YAML(typ="safe").load(text) or {}
if not isinstance(data, dict):
raise ValueError("Vault content must be a YAML mapping")
return data
def get_value(self, customer: str, key: str) -> str:
value = self.view_data(customer).get(key, "")
return str(value or "")
def view_text(self, customer: str) -> str:
path = self.path(customer)
if not path.exists():
raise FileNotFoundError(path)
self._require_command()
result = run_external(
["ansible-vault", "view", "--vault-id", f"{customer}@prompt", str(path)],
cwd=self.customers.require(customer), capture_stdout=True,
)
if result.returncode:
raise ExternalCommandFailed("Vault read failed")
return result.stdout
def consolidate_template(self, customer: str) -> bool:
"""Add missing AIM template keys/comments while preserving all existing values/custom keys."""
path = self.path(customer)
if not path.exists():
raise FileNotFoundError(path)
self._require_command()
original = path.read_bytes()
text = self.view_text(customer)
yaml = YAML()
yaml.preserve_quotes = True
data = yaml.load(text) or {}
if not isinstance(data, dict):
raise ValueError("Vault content must be a YAML mapping")
changed = False
for _, key in VAULT_TEMPLATE_SECTIONS:
if key and key not in data:
data[key] = DoubleQuotedScalarString("")
changed = True
# Rebuild with canonical comments while preserving all existing values/custom keys.
canonical_comments = [comment for comment, _ in VAULT_TEMPLATE_SECTIONS]
if not all(comment in text for comment in canonical_comments):
changed = True
if not changed:
return False
fd, tmp_name = __import__('tempfile').mkstemp(prefix='.aim-vault-', suffix='.yml', dir=str(path.parent))
os.fchmod(fd, 0o600)
tmp = Path(tmp_name)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
emitted = set()
for comment, key in VAULT_TEMPLATE_SECTIONS:
stream.write(comment + "\n")
if key:
yaml.dump({key: data[key]}, stream)
emitted.add(key)
stream.write("\n")
for key, value in data.items():
if key in emitted:
continue
yaml.dump({key: value}, stream)
result = run_external(
["ansible-vault", "encrypt", "--vault-id", f"{customer}@prompt", str(tmp)],
cwd=self.customers.require(customer),
)
if result.returncode:
raise ExternalCommandFailed("Vault encryption failed during consolidation")
if not tmp.read_bytes().startswith(b"$ANSIBLE_VAULT;"):
raise ExternalCommandFailed("Encryption did not produce a Vault document")
if path.read_bytes() != original:
raise ExternalCommandFailed("Vault changed externally; consolidation was not applied")
os.replace(tmp, path)
apply_managed_permissions(path, self.customers.config.required_group)
return True
finally:
tmp.unlink(missing_ok=True)
def edit(self, customer: str) -> None:
path = self.path(customer)
if not path.exists():
raise FileNotFoundError(path)
self._require_command()
env = os.environ.copy()
env.setdefault("EDITOR", "nano")
env.setdefault("VISUAL", env["EDITOR"])
result = run_external(
["ansible-vault", "edit", "--vault-id", f"{customer}@prompt", str(path)],
cwd=self.customers.require(customer),
env=env,
)
if result.returncode:
raise ExternalCommandFailed("Vault edit failed")
apply_managed_permissions(path, self.customers.config.required_group)
def delete(self, customer: str) -> None:
path = self.path(customer)
if not path.exists():
raise FileNotFoundError(path)
path.unlink()
+1
View File
@@ -0,0 +1 @@
"""Windows WinRM access management."""
+646
View File
@@ -0,0 +1,646 @@
from __future__ import annotations
import base64
import os
import tempfile
from pathlib import Path
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from aim.exceptions import ExternalCommandFailed
from aim.external import command_available, run_external
class WinRMManager:
"""Bootstrap and test Windows service-user access over preconfigured WinRM HTTPS.
AIM assumes WinRM HTTPS is already enabled and reachable. Credentials are written
only to a private temporary directory (0700) with files mode 0600 and removed when
the operation finishes. Passwords are never placed in subprocess arguments.
"""
def __init__(self, config):
self.config = config
@staticmethod
def _require_ansible(command: str) -> None:
if not command_available(command):
raise ExternalCommandFailed(f"Required command not found: {command}")
@staticmethod
def _dump_private_yaml(path: Path, data: dict) -> None:
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
yaml.dump(data, stream)
except Exception:
try:
os.close(fd)
except OSError:
pass
raise
path.chmod(0o600)
def _connection_inventory(self, fqdn: str, ip: str, username: str, password: str) -> dict:
return {
"all": {
"hosts": {
fqdn: {
"ansible_host": ip,
"ansible_connection": "winrm",
"ansible_port": 5986,
"ansible_winrm_transport": "ntlm",
"ansible_winrm_server_cert_validation": "ignore",
"ansible_user": username,
"ansible_password": password,
}
}
}
}
def _run_ping(self, fqdn: str, ip: str, username: str, password: str) -> None:
self._require_ansible("ansible")
if not username:
raise ValueError("WinRM username is required")
if not password:
raise ValueError("WinRM password is required")
with tempfile.TemporaryDirectory(prefix="aim-winrm-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
self._dump_private_yaml(inventory, self._connection_inventory(fqdn, ip, username, password))
result = run_external(
["ansible", "all", "-i", str(inventory), "-m", "ansible.windows.win_ping"],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed(f"WinRM connection test failed for {fqdn}")
def test_connection(self, fqdn: str, ip: str, username: str, password: str) -> None:
self._run_ping(fqdn, ip, username, password)
def test_inventory_service_user(self, customer: str, inventory: Path, fqdn: str) -> None:
"""Test WinRM using the inventory's normal vars and Vault credentials."""
self._require_ansible("ansible")
args = [
"ansible", fqdn, "-i", str(inventory),
"-m", "ansible.windows.win_ping",
]
vault = inventory.parent / "group_vars" / "all" / "vault.yml"
if vault.is_file():
args.extend(["--vault-id", f"{customer}@prompt"])
result = run_external(args, cwd=inventory.parent, ui_mode="compact")
if result.returncode:
raise ExternalCommandFailed(f"Service-user WinRM connection test failed for {fqdn}")
def bootstrap_service_user(
self,
fqdn: str,
ip: str,
bootstrap_user: str,
bootstrap_password: str,
service_password: str,
) -> None:
self._require_ansible("ansible-playbook")
if not bootstrap_user:
raise ValueError("Temporary administrator username is required")
if not bootstrap_password:
raise ValueError("Temporary administrator password is required")
if not service_password:
raise ValueError("Service-user password is required")
# Fail early with a clear connectivity/authentication result before changing anything.
self._run_ping(fqdn, ip, bootstrap_user, bootstrap_password)
with tempfile.TemporaryDirectory(prefix="aim-winrm-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
playbook = tmp / "bootstrap.yml"
inv = self._connection_inventory(fqdn, ip, bootstrap_user, bootstrap_password)
inv["all"].setdefault("vars", {})["aim_service_user"] = self.config.service_user
inv["all"]["vars"]["aim_service_password"] = service_password
self._dump_private_yaml(inventory, inv)
playbook_data = [
{
"name": "Bootstrap AIM Windows service account",
"hosts": "all",
"gather_facts": False,
"tasks": [
{
"name": "Create or repair AIM service account",
"no_log": True,
"ansible.windows.win_user": {
"name": "{{ aim_service_user }}",
"password": "{{ aim_service_password }}",
"state": "present",
"groups": ["S-1-5-32-544"],
"groups_action": "add",
},
}
],
}
]
self._dump_private_yaml(playbook, playbook_data)
result = run_external(
["ansible-playbook", "-i", str(inventory), str(playbook)],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed(f"Windows service-user bootstrap failed for {fqdn}")
# Verify the account independently with its own credentials.
self._run_ping(fqdn, ip, self.config.service_user, service_password)
def bootstrap_domain_service_user(
self,
fqdn: str,
ip: str,
bootstrap_user: str,
bootstrap_password: str,
domain_dns: str,
service_password: str,
) -> str:
"""Create or repair the domain-scoped service account in Active Directory."""
self._require_ansible("ansible-playbook")
if not bootstrap_user:
raise ValueError("Temporary domain administrator username is required")
if not bootstrap_password:
raise ValueError("Temporary domain administrator password is required")
domain_dns = domain_dns.strip().lower()
if not domain_dns or "." not in domain_dns:
raise ValueError("A DNS domain such as bitformer.lan is required")
if not service_password:
raise ValueError("Service-user password is required")
self._run_ping(fqdn, ip, bootstrap_user, bootstrap_password)
service_upn = f"{self.config.service_user}@{domain_dns}"
with tempfile.TemporaryDirectory(prefix="aim-winrm-domain-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
playbook = tmp / "bootstrap-domain.yml"
inv = self._connection_inventory(fqdn, ip, bootstrap_user, bootstrap_password)
inv["all"].setdefault("vars", {}).update({
"aim_service_user": self.config.service_user,
"aim_service_upn": service_upn,
"aim_domain_dns": domain_dns,
"aim_service_password_b64": base64.b64encode(service_password.encode("utf-8")).decode("ascii"),
})
self._dump_private_yaml(inventory, inv)
script = r"""$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.DirectoryServices.AccountManagement
$domain = '{{ aim_domain_dns }}'
$sam = '{{ aim_service_user }}'
$upn = '{{ aim_service_upn }}'
$password = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{ aim_service_password_b64 }}'))
if ([string]::IsNullOrEmpty($password)) { throw 'Service password was not supplied' }
$ctx = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('Domain', $domain)
$user = [System.DirectoryServices.AccountManagement.UserPrincipal]::FindByIdentity($ctx, $sam)
if ($null -eq $user) {
$user = New-Object System.DirectoryServices.AccountManagement.UserPrincipal($ctx)
$user.SamAccountName = $sam
$user.UserPrincipalName = $upn
$user.Name = $sam
$user.DisplayName = $sam
$user.Enabled = $true
$user.SetPassword($password)
$user.Save()
} else {
$user.SetPassword($password)
$user.Enabled = $true
if ([string]::IsNullOrEmpty($user.UserPrincipalName)) { $user.UserPrincipalName = $upn }
$user.Save()
}
$adminsSid = 'S-1-5-32-544'
$admins = [System.DirectoryServices.AccountManagement.GroupPrincipal]::FindByIdentity(
$ctx,
[System.DirectoryServices.AccountManagement.IdentityType]::Sid,
$adminsSid
)
if ($null -eq $admins) {
throw "Could not resolve BUILTIN\Administrators ($adminsSid) in domain $domain"
}
if (-not $admins.Members.Contains($user)) {
$admins.Members.Add($user)
$admins.Save()
}
"""
playbook_data = [
{
"name": "Bootstrap AIM Windows domain service account",
"hosts": "all",
"gather_facts": False,
"tasks": [
{
"name": "Create or repair domain service account",
"no_log": True,
"ansible.windows.win_powershell": {
"script": LiteralScalarString(script),
},
}
],
}
]
self._dump_private_yaml(playbook, playbook_data)
result = run_external(
["ansible-playbook", "-i", str(inventory), str(playbook)],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed(f"Windows domain service-user bootstrap failed for {fqdn}")
return service_upn
def grant_domain_service_user_access_batch(
self,
targets: list[tuple[str, str]],
bootstrap_user: str,
bootstrap_password: str,
domain_dns: str,
) -> str:
"""Grant the domain service account local Administrators rights on member servers."""
self._require_ansible("ansible-playbook")
if not targets:
raise ValueError("At least one target server is required")
if not bootstrap_user:
raise ValueError("Temporary administrator username is required")
if not bootstrap_password:
raise ValueError("Temporary administrator password is required")
domain_dns = domain_dns.strip().lower()
if not domain_dns or "." not in domain_dns:
raise ValueError("A DNS domain such as bitformer.lan is required")
service_upn = f"{self.config.service_user}@{domain_dns}"
with tempfile.TemporaryDirectory(prefix="aim-winrm-domain-access-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
playbook = tmp / "grant-domain-access.yml"
hosts = {}
for fqdn, ip in targets:
hosts[fqdn] = {
"ansible_host": ip,
"ansible_connection": "winrm",
"ansible_port": 5986,
"ansible_winrm_transport": "ntlm",
"ansible_winrm_server_cert_validation": "ignore",
"ansible_user": bootstrap_user,
"ansible_password": bootstrap_password,
}
inv = {
"all": {
"hosts": hosts,
"vars": {
"aim_service_user": self.config.service_user,
"aim_service_upn": service_upn,
},
}
}
self._dump_private_yaml(inventory, inv)
script = r"""$ErrorActionPreference = 'Stop'
$computerSystem = Get-CimInstance Win32_ComputerSystem
if ($computerSystem.DomainRole -in 4,5) {
$Ansible.Changed = $false
Write-Output 'Skipped: target is a domain controller.'
return
}
$upn = '{{ aim_service_upn }}'
$sam = '{{ aim_service_user }}'
$admins = Get-LocalGroup -SID 'S-1-5-32-544'
$current = Get-LocalGroupMember -Group $admins -ErrorAction SilentlyContinue | Where-Object {
$_.Name -ieq $upn -or $_.Name -match ('\\' + [regex]::Escape($sam) + '$')
}
if (-not $current) {
Add-LocalGroupMember -Group $admins -Member $upn
$Ansible.Changed = $true
} else {
$Ansible.Changed = $false
}
"""
playbook_data = [
{
"name": "Grant AIM domain service account access on member servers",
"hosts": "all",
"gather_facts": False,
"tasks": [
{
"name": "Grant domain service account local Administrator rights",
"ansible.windows.win_powershell": {
"script": LiteralScalarString(script),
},
}
],
}
]
self._dump_private_yaml(playbook, playbook_data)
result = run_external(
["ansible-playbook", "-i", str(inventory), str(playbook)],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed("Granting domain service-user access failed for one or more targets")
return service_upn
def _run_inventory_powershell(
self,
customer: str,
inventory: Path,
fqdn: str,
script: str,
*,
capture_output: bool = False,
):
"""Run PowerShell on an inventory host using configured WinRM/Vault credentials."""
self._require_ansible("ansible-playbook")
with tempfile.TemporaryDirectory(prefix="aim-winrm-domain-rollout-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
playbook = tmp / "domain-rollout.yml"
tasks = [
{
"name": "Run AIM domain rollout operation",
"ansible.windows.win_powershell": {
"script": LiteralScalarString(script),
},
"register": "aim_domain_rollout_result",
}
]
result_path = tmp / "powershell-output.txt"
if capture_output:
tasks.append(
{
"name": "Capture AIM domain rollout output",
"ansible.builtin.copy": {
"content": "{{ (aim_domain_rollout_result.output | default([])) | join('\n') }}",
"dest": str(result_path),
"mode": "0600",
},
"delegate_to": "localhost",
"become": False,
}
)
self._dump_private_yaml(
playbook,
[
{
"name": "AIM domain rollout",
"hosts": fqdn,
"gather_facts": False,
"tasks": tasks,
}
],
)
args = ["ansible-playbook", "-i", str(inventory), str(playbook)]
vault = inventory.parent / "group_vars" / "all" / "vault.yml"
if vault.is_file():
args.extend(["--vault-id", f"{customer}@prompt"])
result = run_external(args, cwd=inventory.parent, capture_output=capture_output, ui_mode="compact")
if capture_output and result.returncode == 0 and result_path.is_file():
result.stdout = result_path.read_text(encoding="utf-8")
result.stderr = result.stderr or ""
return result
def list_domain_ous(self, customer: str, inventory: Path, dc_fqdn: str) -> list[tuple[str, str]]:
"""Return (name, distinguishedName) pairs from the prepared domain controller."""
import json
import re
script = r"""$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory
$items = Get-ADOrganizationalUnit -Filter * -Properties DistinguishedName |
Sort-Object DistinguishedName |
Select-Object Name, DistinguishedName
$json = @($items) | ConvertTo-Json -Compress -Depth 3
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$b64 = [Convert]::ToBase64String($bytes)
Write-Output ("AIM_OUS_B64=" + $b64)
"""
result = self._run_inventory_powershell(customer, inventory, dc_fqdn, script, capture_output=True)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
raise ExternalCommandFailed(f"Could not query domain OUs from {dc_fqdn}: {detail}")
combined = (result.stdout or "") + "\n" + (result.stderr or "")
if not (result.stdout or "").strip():
raise ExternalCommandFailed(
"Domain OU query succeeded but returned no PowerShell output"
)
match = re.search(r'(?:^|\s)AIM_OUS_B64=([A-Za-z0-9+/=]+)(?:$|\s)', combined)
if not match:
preview = " ".join((result.stdout or "").split())[:240]
raise ExternalCommandFailed(
"Domain OU query succeeded but AIM could not parse the OU list. "
f"Sanitized output: {preview or '<empty>'}"
)
try:
import base64
decoded = base64.b64decode(match.group(1), validate=True).decode("utf-8")
data = json.loads(decoded)
except Exception as exc:
raise ExternalCommandFailed(f"Domain OU query returned unreadable data: {exc}") from exc
if isinstance(data, dict):
data = [data]
return [
(str(item.get("Name", "")), str(item.get("DistinguishedName", "")))
for item in data
if item.get("DistinguishedName")
]
def deploy_domain_winrm_gpo(
self,
customer: str,
inventory: Path,
dc_fqdn: str,
ou_dn: str,
additional_sans: list[str] | None = None,
) -> None:
"""Create/repair the AIM AD group and WinRM rollout GPO using the prepared DC."""
import json
payload = {
"ou_dn": ou_dn,
"group_name": "GG_bitformer_Ansible_Admins",
"gpo_name": "bitformer - Ansible WinRM",
"task_name": "bitformer - Configure Ansible WinRM",
"service_user": self.config.service_user,
"additional_sans": additional_sans or [],
}
payload_b64 = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
script = r"""$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory
Import-Module GroupPolicy
$cfg = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__AIM_PAYLOAD_B64__')) | ConvertFrom-Json
$domain = Get-ADDomain
$ou = Get-ADOrganizationalUnit -Identity $cfg.ou_dn -Properties DistinguishedName
if ($ou.DistinguishedName -ieq $domain.DomainControllersContainer) {
throw 'The Domain Controllers OU is not a valid target for the member-server WinRM rollout.'
}
$serviceUser = Get-ADUser -Identity $cfg.service_user -ErrorAction Stop
$groupName = [string]$cfg.group_name
$groupFilterName = $groupName.Replace("'", "''")
$group = Get-ADGroup -Filter "SamAccountName -eq '$groupFilterName'" | Select-Object -First 1
if (-not $group) {
$group = New-ADGroup -Name $cfg.group_name -SamAccountName $cfg.group_name -GroupScope Global -GroupCategory Security -Path $domain.UsersContainer -PassThru
}
$member = Get-ADGroupMember -Identity $group -Recursive -ErrorAction SilentlyContinue | Where-Object { $_.DistinguishedName -eq $serviceUser.DistinguishedName }
if (-not $member) { Add-ADGroupMember -Identity $group -Members $serviceUser }
$gpoName = [string]$cfg.gpo_name
$gpo = Get-GPO -All | Where-Object { $_.DisplayName -eq $gpoName } | Select-Object -First 1
if (-not $gpo) {
$gpo = New-GPO -Name $cfg.gpo_name -Comment 'Managed by AIM: WinRM HTTPS bootstrap and Ansible local administrator access.'
}
$existingLink = (Get-GPInheritance -Target $ou.DistinguishedName).GpoLinks | Where-Object { $_.DisplayName -eq $cfg.gpo_name }
if (-not $existingLink) { New-GPLink -Name $cfg.gpo_name -Target $ou.DistinguishedName -LinkEnabled Yes | Out-Null }
$guid = '{' + $gpo.Id.Guid.ToString().ToUpperInvariant() + '}'
$policyRoot = "\\$($domain.DNSRoot)\SYSVOL\$($domain.DNSRoot)\Policies\$guid\Machine"
$preferences = Join-Path $policyRoot 'Preferences'
$groupsDir = Join-Path $preferences 'Groups'
$tasksDir = Join-Path $preferences 'ScheduledTasks'
$scriptsDir = Join-Path $policyRoot 'Scripts'
New-Item -ItemType Directory -Path $groupsDir,$tasksDir,$scriptsDir -Force | Out-Null
$netbios = $domain.NetBIOSName
$groupSid = $group.SID.Value
$groupUid = '{' + ([guid]::NewGuid().ToString().ToUpperInvariant()) + '}'
$changed = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$groupsXml = @"
<?xml version="1.0" encoding="utf-8"?>
<Groups clsid="{3125E937-EB16-4b4c-9934-544FC6D24D26}" disabled="0">
<Group clsid="{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}" name="Administrators" image="2" changed="$changed" uid="$groupUid">
<Properties action="U" newName="" description="AIM-managed Ansible administrators" userAction="ADD" deleteAllUsers="0" deleteAllGroups="0" removeAccounts="0" groupName="Administrators" groupSid="S-1-5-32-544">
<Members><Member name="$netbios\$($cfg.group_name)" action="ADD" sid="$groupSid" /></Members>
</Properties>
</Group>
</Groups>
"@
[IO.File]::WriteAllText((Join-Path $groupsDir 'Groups.xml'), $groupsXml, [Text.UTF8Encoding]::new($false))
$extraSansLiteral = @($cfg.additional_sans | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
$taskScript = @"
`$ErrorActionPreference = 'Stop'
`$taskName = '$($cfg.task_name)'
try {
Set-Service -Name WinRM -StartupType Automatic
Start-Service -Name WinRM
`$hostName = `$env:COMPUTERNAME
try { `$fqdn = [System.Net.Dns]::GetHostEntry(`$env:COMPUTERNAME).HostName } catch { `$fqdn = `$hostName }
`$hostIPs = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { `$_.IPAddress -notlike '127.*' -and `$_.IPAddress -notlike '169.254.*' } | Select-Object -ExpandProperty IPAddress -Unique)
`$additionalSans = @($extraSansLiteral)
`$sans = @(`$hostName, `$fqdn) + `$hostIPs + `$additionalSans | Where-Object { `$_ } | Select-Object -Unique
`$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { `$_.FriendlyName -eq 'WinRM' -and `$_.NotAfter -gt (Get-Date).AddDays(7) } | Sort-Object NotAfter -Descending | Select-Object -First 1
if (-not `$cert) {
`$cert = New-SelfSignedCertificate -DnsName `$sans -TextExtension '2.5.29.37={text}1.3.6.1.5.5.7.3.1' -FriendlyName 'WinRM' -CertStoreLocation 'Cert:\LocalMachine\My'
}
`$httpsListener = Get-ChildItem WSMan:\localhost\Listener -ErrorAction SilentlyContinue | Where-Object { `$_.Keys -contains 'Transport=HTTPS' } | Select-Object -First 1
if (-not `$httpsListener) { New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbPrint `$cert.Thumbprint -Force | Out-Null }
`$rule = Get-NetFirewallRule -DisplayName 'Windows Remote Management (HTTPS-In)' -ErrorAction SilentlyContinue
if (-not `$rule) { New-NetFirewallRule -DisplayName 'Windows Remote Management (HTTPS-In)' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Program System | Out-Null } else { `$rule | Enable-NetFirewallRule | Out-Null }
`$listenerOk = @(Get-ChildItem WSMan:\localhost\Listener -ErrorAction SilentlyContinue | Where-Object { `$_.Keys -contains 'Transport=HTTPS' }).Count -gt 0
`$firewallOk = @(Get-NetFirewallRule -DisplayName 'Windows Remote Management (HTTPS-In)' -ErrorAction SilentlyContinue | Where-Object Enabled -eq 'True').Count -gt 0
`$serviceOk = (Get-Service WinRM).Status -eq 'Running'
if (-not (`$listenerOk -and `$firewallOk -and `$serviceOk)) { throw 'WinRM rollout verification failed' }
Disable-ScheduledTask -TaskName `$taskName -ErrorAction SilentlyContinue | Out-Null
exit 0
} catch { Write-Error `$_; exit 1 }
"@
$scriptPath = Join-Path $scriptsDir 'AIM-WinRM-Setup.ps1'
[IO.File]::WriteAllText($scriptPath, $taskScript, [Text.UTF8Encoding]::new($false))
$scriptUnc = "\\$($domain.DNSRoot)\SYSVOL\$($domain.DNSRoot)\Policies\$guid\Machine\Scripts\AIM-WinRM-Setup.ps1"
$taskUid = '{' + ([guid]::NewGuid().ToString().ToUpperInvariant()) + '}'
$escapedScript = [Security.SecurityElement]::Escape($scriptUnc)
$taskXml = @"
<?xml version="1.0" encoding="utf-8"?>
<ScheduledTasks clsid="{CC63F200-7309-4ba0-B154-A71CD118DBCC}" disabled="0">
<TaskV2 clsid="{D8896631-B747-47a7-84A6-C155337F3BC8}" name="$($cfg.task_name)" image="2" changed="$changed" uid="$taskUid" userContext="0" removePolicy="0" desc="AIM WinRM HTTPS bootstrap">
<Properties action="U" name="$($cfg.task_name)" runAs="NT AUTHORITY\SYSTEM" logonType="Service Account">
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo><Author>bitformer AIM</Author><Description>AIM WinRM HTTPS bootstrap</Description></RegistrationInfo>
<Principals><Principal id="Author"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable><RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable><AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><Hidden>false</Hidden><ExecutionTimeLimit>PT10M</ExecutionTimeLimit><Priority>7</Priority></Settings>
<Triggers>
<RegistrationTrigger><Enabled>true</Enabled><Repetition><Interval>PT5M</Interval><Duration>PT1H</Duration><StopAtDurationEnd>false</StopAtDurationEnd></Repetition></RegistrationTrigger>
<BootTrigger><Enabled>true</Enabled><Repetition><Interval>PT5M</Interval><Duration>PT1H</Duration><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><Delay>PT2M</Delay></BootTrigger>
</Triggers>
<Actions><Exec><Command>%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe</Command><Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -File &quot;$escapedScript&quot;</Arguments></Exec></Actions>
</Task>
</Properties>
</TaskV2>
</ScheduledTasks>
"@
[IO.File]::WriteAllText((Join-Path $tasksDir 'ScheduledTasks.xml'), $taskXml, [Text.UTF8Encoding]::new($false))
$gpoDn = "CN=$guid,CN=Policies,CN=System,$($domain.DistinguishedName)"
$gpoAd = Get-ADObject -Identity $gpoDn -Properties gPCMachineExtensionNames
$ext = [string]$gpoAd.gPCMachineExtensionNames
$pairs = @(
'[{17D89FEC-5C44-4972-B12D-241CAEF74509}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]',
'[{AADCED64-746C-4633-A97C-D61349046527}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}]'
)
foreach ($pair in $pairs) {
$cse = $pair.Substring(1, 38)
if ($ext -notlike "*$cse*") { $ext += $pair }
}
Set-ADObject -Identity $gpoDn -Replace @{ gPCMachineExtensionNames = $ext }
$stamp = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
Set-GPRegistryValue -Name $cfg.gpo_name -Key 'HKLM\Software\Policies\bitformer\AIM' -ValueName 'WinRMRolloutRevision' -Type DWord -Value $stamp | Out-Null
# Verify that Group Policy Management can render both XML and HTML reports.
# The HTML renderer is stricter about malformed/unsupported preference payloads.
$reportBase = Join-Path $env:TEMP ("aim-gpo-report-" + [guid]::NewGuid().ToString())
$xmlReport = $reportBase + '.xml'
$htmlReport = $reportBase + '.html'
try {
Get-GPOReport -Guid $gpo.Id -ReportType Xml -Path $xmlReport -ErrorAction Stop
Get-GPOReport -Guid $gpo.Id -ReportType Html -Path $htmlReport -ErrorAction Stop
if (-not (Test-Path $htmlReport) -or (Get-Item $htmlReport).Length -eq 0) {
throw 'GPMC produced an empty HTML report.'
}
} catch {
throw "AIM created/repaired the GPO, but GPMC report validation failed: $($_.Exception.Message)"
} finally {
Remove-Item $xmlReport,$htmlReport -Force -ErrorAction SilentlyContinue
}
Write-Output "AIM_GPO_NAME=$($cfg.gpo_name)"
Write-Output "AIM_GROUP_NAME=$($cfg.group_name)"
Write-Output "AIM_OU_DN=$($ou.DistinguishedName)"
""".replace('__AIM_PAYLOAD_B64__', payload_b64)
result = self._run_inventory_powershell(customer, inventory, dc_fqdn, script)
if result.returncode:
raise ExternalCommandFailed(f"Domain WinRM GPO deployment failed on {dc_fqdn}")
def grant_domain_service_user_access(
self,
fqdn: str,
ip: str,
bootstrap_user: str,
bootstrap_password: str,
domain_dns: str,
) -> str:
"""Backward-compatible one-host wrapper."""
return self.grant_domain_service_user_access_batch(
[(fqdn, ip)], bootstrap_user, bootstrap_password, domain_dns
)