Compare commits
2 Commits
db60b5a2ea
...
7c24c7ba7f
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c24c7ba7f | |||
| 343fe9b22f |
@@ -0,0 +1,90 @@
|
|||||||
|
# AIM Changelog
|
||||||
|
|
||||||
|
## 2.1.x
|
||||||
|
|
||||||
|
### 2.1.2
|
||||||
|
|
||||||
|
- Removed the experimental ASCII shield.
|
||||||
|
- Retained bitformer orange `#ff7a00` accent.
|
||||||
|
- Added compact `bitformer · AIM · Ansible Inventory Manager` header.
|
||||||
|
- Preserved `p / n` pagination convention.
|
||||||
|
- No intended Ansible behavior changes.
|
||||||
|
|
||||||
|
### 2.1.1
|
||||||
|
|
||||||
|
- Changed the primary UI accent to bitformer orange.
|
||||||
|
- Introduced temporary ASCII branding experiment.
|
||||||
|
- Standardized pagination on `p / n`.
|
||||||
|
|
||||||
|
### 2.1.0
|
||||||
|
|
||||||
|
- Major operator-console UI/UX refactor.
|
||||||
|
- Split presentation into focused UI modules.
|
||||||
|
- Added customer overview/dashboard.
|
||||||
|
- Unified selectors, review screens, result presentation, pagination
|
||||||
|
and filtering.
|
||||||
|
- Kept interactive commands live where password/editor interaction is
|
||||||
|
required.
|
||||||
|
|
||||||
|
## 2.0.x
|
||||||
|
|
||||||
|
- Added domain WinRM GPO rollout.
|
||||||
|
- Added malformed-YAML handling and multiple GPO/AD/GPP reliability
|
||||||
|
hotfixes.
|
||||||
|
- Corrected Windows local-account credential handling to be
|
||||||
|
host-specific.
|
||||||
|
- Corrected new-customer domain UPN defaults.
|
||||||
|
- Final 2.0.7 GPP Scheduled Task XML fix removed the invalid inner
|
||||||
|
`LogonType` element and added immediate registration
|
||||||
|
execution/retries.
|
||||||
|
|
||||||
|
## 1.9.x
|
||||||
|
|
||||||
|
- Added semantic Git-style template/Vault validation.
|
||||||
|
- Added non-destructive template consolidation.
|
||||||
|
- Consolidated playbook categories and generic multi-select behavior.
|
||||||
|
|
||||||
|
## 1.8.x
|
||||||
|
|
||||||
|
- Added structured Vault template creation.
|
||||||
|
- Added Windows credential models.
|
||||||
|
- Added SSH-agent/private-key-passphrase integration.
|
||||||
|
- Added direct multi-select workflows.
|
||||||
|
|
||||||
|
## 1.7.x
|
||||||
|
|
||||||
|
- Added customer domain/network defaults.
|
||||||
|
- Added batch Windows member-server domain access.
|
||||||
|
|
||||||
|
## 1.6.x
|
||||||
|
|
||||||
|
- Added/expanded automated Sophos configuration.
|
||||||
|
- Improved lock cleanup and Sophos menu organization.
|
||||||
|
|
||||||
|
## 1.5.x
|
||||||
|
|
||||||
|
- Added Sophos defaults, host variables and customer-specific playbook
|
||||||
|
support.
|
||||||
|
|
||||||
|
## 1.4.x
|
||||||
|
|
||||||
|
- Split Windows domain-account and member-server access workflows.
|
||||||
|
|
||||||
|
## 1.3.x
|
||||||
|
|
||||||
|
- Added local/domain Windows account flows and safer credential
|
||||||
|
transport.
|
||||||
|
|
||||||
|
## 1.2.x
|
||||||
|
|
||||||
|
- Added Windows WinRM service-account bootstrap and localized
|
||||||
|
Administrators handling.
|
||||||
|
|
||||||
|
## 1.1.x
|
||||||
|
|
||||||
|
- Added curated playbooks, routine local-only inventory validation,
|
||||||
|
empty default host-vars files and Enter=`0` navigation behavior.
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
- Initial consolidated Python AIM base.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# AIM Development Guide
|
||||||
|
|
||||||
|
## Project root
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/scripts/
|
||||||
|
├── pyproject.toml
|
||||||
|
└── src/
|
||||||
|
└── aim/
|
||||||
|
```
|
||||||
|
|
||||||
|
The environment is installed editable:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
/etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Backend areas include inventory loading/writing/validation, customer
|
||||||
|
management, backup/session recovery, locking, permissions, playbooks,
|
||||||
|
SSH, Vault, Sophos and WinRM.
|
||||||
|
|
||||||
|
The 2.1 UI is split into focused modules under `src/aim/ui/`, including
|
||||||
|
shared components, selection, execution, customers, hosts, access,
|
||||||
|
playbooks, administration and target selection.
|
||||||
|
|
||||||
|
## UI conventions
|
||||||
|
|
||||||
|
- Orange `#ff7a00` is the AIM/bitformer accent.
|
||||||
|
- Green = success.
|
||||||
|
- Yellow = warning.
|
||||||
|
- Red = failure.
|
||||||
|
- No ASCII logo.
|
||||||
|
- Header identity: `bitformer · AIM · Ansible Inventory Manager`.
|
||||||
|
- Pagination: `p / n`.
|
||||||
|
- Numbered navigation: Enter/`0` = Back or Cancel.
|
||||||
|
- Multi-select: number toggles; Enter reviews; `0` cancels.
|
||||||
|
|
||||||
|
Opening menus should not unexpectedly run Ansible, contact hosts,
|
||||||
|
decrypt Vaults or prompt for passwords.
|
||||||
|
|
||||||
|
## Inventory invariants
|
||||||
|
|
||||||
|
`hosts.yml` is authoritative. Preserve arbitrary valid YAML/custom
|
||||||
|
keys/comments where possible.
|
||||||
|
|
||||||
|
Writes should be validated and atomic, with stale-write/concurrency
|
||||||
|
protection and a session recovery backup.
|
||||||
|
|
||||||
|
Do not change unrelated Ansible connection/authentication parameters as
|
||||||
|
part of feature work.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Production packages should contain runtime source and metadata, without
|
||||||
|
caches, `.pyc`, test artifacts, legacy Bash implementations or developer
|
||||||
|
notes unless explicitly requested.
|
||||||
|
|
||||||
|
A release archive should expose `pyproject.toml` and `src/` at its root
|
||||||
|
rather than adding an extra wrapper directory.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Use a patch release for contained fixes and a feature/minor release for
|
||||||
|
larger functional changes. Update package metadata and the changelog
|
||||||
|
together.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# AIM Inventory Model
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
The authoritative inventory is:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/inventories/<customer>/hosts.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
AIM does not use `.hosts.tsv` as a secondary database and does not
|
||||||
|
reverse-sync TSV data into YAML.
|
||||||
|
|
||||||
|
AIM uses round-trip YAML handling so valid manually maintained
|
||||||
|
structures/comments can be preserved where possible.
|
||||||
|
|
||||||
|
## Platform groups
|
||||||
|
|
||||||
|
Default top-level platform groups:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
linux
|
||||||
|
windows
|
||||||
|
sophosxgs
|
||||||
|
pfsense
|
||||||
|
```
|
||||||
|
|
||||||
|
Platform remains top-level because it determines connection semantics
|
||||||
|
such as SSH, WinRM or HTTPAPI.
|
||||||
|
|
||||||
|
Hosts may have multiple memberships and optional one-level functional
|
||||||
|
subgroups.
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
Linux group variables normally include the SSH connection and service
|
||||||
|
account. The customer SSH key directory is:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
group_vars/linux/.ssh/
|
||||||
|
```
|
||||||
|
|
||||||
|
not:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
group_vars/linux/files/.ssh/
|
||||||
|
```
|
||||||
|
|
||||||
|
`ansible_ssh_pass` may remain configured as a legacy
|
||||||
|
remote-login-password fallback. A private-key passphrase is a separate
|
||||||
|
secret.
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
Domain-joined Windows hosts normally inherit the group-level service
|
||||||
|
identity/password.
|
||||||
|
|
||||||
|
A local-account host can override credentials in:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
host_vars/<fqdn>/main.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared local example:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
ansible_user: svc_bf-ansible
|
||||||
|
ansible_password: "{{ vault_windows_local_ansible_password }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Host-specific example:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
ansible_user: svc_bf-ansible
|
||||||
|
ansible_password: "{{ vault_ansible_password_server01_example_lan }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Host vars
|
||||||
|
|
||||||
|
Every newly managed host has:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
host_vars/<fqdn>/main.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing host-vars content is not blindly overwritten.
|
||||||
|
|
||||||
|
## Customer defaults
|
||||||
|
|
||||||
|
AIM customer defaults live in:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/inventories/<customer>/.aim.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
domain_suffix: bfmiglabor.lan
|
||||||
|
network_address: 10.20.30.0
|
||||||
|
netmask: 255.255.255.0
|
||||||
|
ad_dns_domain: intra.company.de
|
||||||
|
ad_netbios_domain: COMPANY
|
||||||
|
```
|
||||||
|
|
||||||
|
The AD DNS domain is used for service-account UPNs. NetBIOS remains
|
||||||
|
metadata/legacy naming information.
|
||||||
|
|
||||||
|
## Safe writes
|
||||||
|
|
||||||
|
Inventory mutations use the conceptual sequence:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
candidate temp file
|
||||||
|
→ local YAML validation
|
||||||
|
→ compare
|
||||||
|
→ session backup
|
||||||
|
→ atomic replace
|
||||||
|
```
|
||||||
|
|
||||||
|
AIM also protects against stale/concurrent writes and uses an inventory
|
||||||
|
lock.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# AIM Operations Guide
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
AIM is organized around a customer context:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
Customer
|
||||||
|
├── Hosts Management
|
||||||
|
├── Access Management
|
||||||
|
├── Vault Management
|
||||||
|
├── Group Variables
|
||||||
|
├── Host Variables
|
||||||
|
├── Playbooks
|
||||||
|
└── Administration
|
||||||
|
```
|
||||||
|
|
||||||
|
For numbered navigation menus, Enter or `0` means Back/Cancel; at the
|
||||||
|
main menu it means Exit. Single selectors use Enter/`0` to cancel.
|
||||||
|
Multi-select uses numbers to toggle, Enter to review, and `0` to cancel.
|
||||||
|
Paginated views use `p / n` for Previous / Next.
|
||||||
|
|
||||||
|
## Customer overview
|
||||||
|
|
||||||
|
Opening a customer should provide local information without unexpectedly
|
||||||
|
contacting hosts, running Ansible or decrypting Vaults. The dashboard
|
||||||
|
includes inventory YAML state, Vault presence, host/platform counts and
|
||||||
|
available customer defaults.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
`hosts.yml` is the source of truth.
|
||||||
|
|
||||||
|
Creating a host also ensures:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
host_vars/<fqdn>/main.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
For ordinary non-Sophos hosts this file may be empty. Existing host
|
||||||
|
variable files are not overwritten. Removing a host also removes its
|
||||||
|
corresponding host-vars directory.
|
||||||
|
|
||||||
|
Routine add/update/remove operations perform local YAML validation and
|
||||||
|
do not request the Vault password.
|
||||||
|
|
||||||
|
## Access Management
|
||||||
|
|
||||||
|
Linux access manages SSH keys/service-user access.
|
||||||
|
|
||||||
|
Windows access includes temporary WinRM testing, local account creation,
|
||||||
|
domain account creation/repair, member-server domain access, domain
|
||||||
|
WinRM GPO rollout and configured-service-user testing.
|
||||||
|
|
||||||
|
See `WINDOWS.md` for the Windows model.
|
||||||
|
|
||||||
|
## Vault Management
|
||||||
|
|
||||||
|
Vault operations include information, create, edit and delete.
|
||||||
|
|
||||||
|
A new Vault is populated as plaintext with mode `0600`, YAML-validated,
|
||||||
|
then encrypted using:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
ansible-vault encrypt --vault-id <customer>@prompt vault.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
A failed encryption must not leave populated plaintext secrets behind.
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
Group and host variable views are generally operator-readable without
|
||||||
|
AIM rewriting arbitrary custom configuration. AIM only changes values in
|
||||||
|
workflows explicitly designed to do so.
|
||||||
|
|
||||||
|
Template consolidation is explicit and non-destructive: missing AIM
|
||||||
|
defaults/comments can be added, while existing non-empty values and
|
||||||
|
custom keys are retained.
|
||||||
|
|
||||||
|
## Playbooks
|
||||||
|
|
||||||
|
Curated categories include CheckMK, Debug, Maintenance and Sophos XGS.
|
||||||
|
Compatible host/group selection is used to build the Ansible `--limit`.
|
||||||
|
|
||||||
|
Interactive playbooks and operations that require password/Vault prompts
|
||||||
|
retain live terminal access.
|
||||||
|
|
||||||
|
## Administration
|
||||||
|
|
||||||
|
Administration includes inventory validation, template consolidation,
|
||||||
|
recovery and customer defaults.
|
||||||
|
|
||||||
|
Explicit inventory validation performs YAML parsing and
|
||||||
|
`ansible-inventory`. When a customer Vault exists, validation uses the
|
||||||
|
customer's Vault identity and may prompt for its password.
|
||||||
|
|
||||||
|
AIM maintains one pre-change inventory recovery backup per inventory per
|
||||||
|
AIM process/session:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
hosts.aim-session.bak.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Restores are explicit and YAML-validated.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# AIM Recovery Guide
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
Git protects source code, not AIM's ignored runtime secrets.
|
||||||
|
|
||||||
|
A `git reset --hard` or fresh checkout cannot restore ignored data such
|
||||||
|
as inventory Vaults, SSH keys or a Python virtual environment.
|
||||||
|
|
||||||
|
## Critical persistent data
|
||||||
|
|
||||||
|
Back up separately:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/inventories/<customer>/group_vars/all/vault.yml
|
||||||
|
/etc/ansible/inventories/<customer>/group_vars/linux/.ssh/
|
||||||
|
```
|
||||||
|
|
||||||
|
Other customer inventory YAML should also be covered by the system
|
||||||
|
backup policy.
|
||||||
|
|
||||||
|
## Rebuildable data
|
||||||
|
|
||||||
|
Do not recover `.venv` from Git. Rebuild it:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo rm -rf /etc/ansible/.venv
|
||||||
|
sudo python3 -m venv /etc/ansible/.venv
|
||||||
|
sudo /etc/ansible/.venv/bin/python -m pip install --upgrade pip setuptools wheel
|
||||||
|
sudo /etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
|
||||||
|
sudo ln -sfn /etc/ansible/.venv/bin/aim /usr/local/bin/aim
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recover Vaults and SSH keys from a filesystem backup
|
||||||
|
|
||||||
|
When restoring from a backup tree, copy only files that do not already
|
||||||
|
exist in the active inventory. Never overwrite surviving current secrets
|
||||||
|
as part of a bulk recovery.
|
||||||
|
|
||||||
|
See `../install.md` for the current dry-run and restore commands.
|
||||||
|
|
||||||
|
## AIM session inventory backup
|
||||||
|
|
||||||
|
AIM can create:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
hosts.aim-session.bak.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a pre-change recovery aid, not the primary backup strategy for
|
||||||
|
customer secrets.
|
||||||
|
|
||||||
|
Restore from it only through an explicit recovery decision after
|
||||||
|
validating the relevant inventory state.
|
||||||
|
|
||||||
|
## Post-recovery validation
|
||||||
|
|
||||||
|
Before deleting the backup source:
|
||||||
|
|
||||||
|
1. Confirm Vault files exist for expected customers.
|
||||||
|
2. Confirm SSH private/public key files and permissions.
|
||||||
|
3. Test Vault decryption/access for representative customers.
|
||||||
|
4. Test Linux SSH authentication.
|
||||||
|
5. Test Windows WinRM for representative domain/local credential
|
||||||
|
models.
|
||||||
|
6. Test any other customer-specific access that depends on recovered
|
||||||
|
secrets.
|
||||||
|
|
||||||
|
Keep the filesystem backup until these checks pass.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# AIM Sensitive Data and Security Notes
|
||||||
|
|
||||||
|
## Sensitive files
|
||||||
|
|
||||||
|
AIM deliberately keeps secrets outside Git.
|
||||||
|
|
||||||
|
Important locations include:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/inventories/<customer>/group_vars/all/vault.yml
|
||||||
|
/etc/ansible/inventories/<customer>/group_vars/linux/.ssh/
|
||||||
|
```
|
||||||
|
|
||||||
|
Vault files may contain Windows and Linux authentication material.
|
||||||
|
`.ssh` directories may contain private keys that cannot be regenerated
|
||||||
|
without coordinating key rotation on managed systems.
|
||||||
|
|
||||||
|
## Backup requirement
|
||||||
|
|
||||||
|
Git is not a backup for ignored secrets.
|
||||||
|
|
||||||
|
System backup procedures must include customer Vaults and SSH key
|
||||||
|
material. Recovery should preserve existing current files and restore
|
||||||
|
only missing data unless an operator explicitly chooses otherwise.
|
||||||
|
|
||||||
|
## Vault handling
|
||||||
|
|
||||||
|
AIM encrypts newly created Vaults with `ansible-vault`. Plaintext
|
||||||
|
populated Vault data should not remain on disk after a failed encryption
|
||||||
|
attempt.
|
||||||
|
|
||||||
|
Semantic Vault comparison must not print secret values. It should
|
||||||
|
compare key existence/state rather than exposing plaintext.
|
||||||
|
|
||||||
|
## SSH key handling
|
||||||
|
|
||||||
|
Private-key passphrases and remote SSH passwords are separate concepts.
|
||||||
|
|
||||||
|
The Linux private key location is:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
group_vars/linux/.ssh/svc_bf-ansible
|
||||||
|
```
|
||||||
|
|
||||||
|
Private keys should have restrictive filesystem permissions.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
AIM authorization is based on membership in a configured group resolved
|
||||||
|
through NSS/SSSD/winbind/local group services. The configured group name
|
||||||
|
is authoritative; numeric GIDs may differ across hosts.
|
||||||
|
|
||||||
|
Operators should verify effective membership with:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
getent group '<required-group>'
|
||||||
|
id
|
||||||
|
```
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# Windows, WinRM and Active Directory
|
||||||
|
|
||||||
|
## Service account
|
||||||
|
|
||||||
|
The standard service account is:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
svc_bf-ansible
|
||||||
|
```
|
||||||
|
|
||||||
|
For an AD domain, the configured identity is normally the UPN:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
svc_bf-ansible@<ad_dns_domain>
|
||||||
|
```
|
||||||
|
|
||||||
|
The normal domain-account Vault variable is:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
vault_windows_ansible_password: "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared-local-account Vault variable is:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
vault_windows_local_ansible_password: "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows credential models
|
||||||
|
|
||||||
|
New Windows hosts can use:
|
||||||
|
|
||||||
|
1. Domain service account
|
||||||
|
2. Shared local service account
|
||||||
|
3. Host-specific local service account
|
||||||
|
|
||||||
|
Local credential choices are host-specific overrides and must not
|
||||||
|
rewrite `group_vars/windows/main.yml` for every Windows machine.
|
||||||
|
|
||||||
|
## Domain account creation
|
||||||
|
|
||||||
|
AIM can create/repair the domain service identity and add it to the
|
||||||
|
appropriate built-in Administrators context used by the current design.
|
||||||
|
It does not make the account a Domain Admin.
|
||||||
|
|
||||||
|
Administrative bootstrap credentials should only be requested where
|
||||||
|
genuinely required.
|
||||||
|
|
||||||
|
## Member-server access
|
||||||
|
|
||||||
|
AIM can grant the existing domain service identity local Administrators
|
||||||
|
membership on selected member servers. Domain Controllers are
|
||||||
|
rejected/skipped for this workflow.
|
||||||
|
|
||||||
|
## Domain WinRM GPO rollout
|
||||||
|
|
||||||
|
The rollout uses one prepared, already-manageable Domain Controller as
|
||||||
|
its administration point.
|
||||||
|
|
||||||
|
The managed objects are:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
AD group: GG_bitformer_Ansible_Admins
|
||||||
|
GPO: bitformer - Ansible WinRM
|
||||||
|
Task: bitformer - Configure Ansible WinRM
|
||||||
|
```
|
||||||
|
|
||||||
|
The GPO configures the local Administrators membership, deploys the
|
||||||
|
WinRM setup payload/scheduled task, configures HTTPS WinRM and firewall
|
||||||
|
access, and verifies the resulting state.
|
||||||
|
|
||||||
|
### Multiple target OUs
|
||||||
|
|
||||||
|
A single GPO should be linked to multiple selected OUs rather than
|
||||||
|
creating a separate GPO for Servers, Clients, etc.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
bitformer - Ansible WinRM
|
||||||
|
├── OU=Servers,DC=intra,DC=company,DC=de
|
||||||
|
└── OU=Clients,DC=intra,DC=company,DC=de
|
||||||
|
```
|
||||||
|
|
||||||
|
The OU selector should therefore support multi-selection.
|
||||||
|
|
||||||
|
GPO link management is **additive and idempotent**:
|
||||||
|
|
||||||
|
- Selected OU already linked: keep/repair as appropriate.
|
||||||
|
- Selected OU not linked: create the link.
|
||||||
|
- Unselected OU: do nothing.
|
||||||
|
|
||||||
|
Selecting only `Servers` on a later run must **not** imply that an
|
||||||
|
existing `Clients` link should be removed.
|
||||||
|
|
||||||
|
Link removal should be an explicit operation if/when AIM implements it.
|
||||||
|
|
||||||
|
### Child OUs
|
||||||
|
|
||||||
|
AIM links the GPO to the selected OU. It should not create redundant
|
||||||
|
links on every descendant OU merely to emulate inheritance. Normal Group
|
||||||
|
Policy inheritance handles descendants unless AD policy configuration
|
||||||
|
changes that behavior.
|
||||||
|
|
||||||
|
### Domain Controllers
|
||||||
|
|
||||||
|
The Domain Controllers OU must remain unavailable/rejected for the
|
||||||
|
normal member-machine WinRM rollout.
|
||||||
|
|
||||||
|
## WinRM payload
|
||||||
|
|
||||||
|
The payload ensures WinRM is running, configures/reuses a suitable
|
||||||
|
certificate or creates a self-signed Server Authentication certificate,
|
||||||
|
creates the HTTPS listener, allows TCP/5986 and verifies the final
|
||||||
|
state.
|
||||||
|
|
||||||
|
The scheduled task runs immediately after registration and can retry
|
||||||
|
periodically. After successful verification it disables itself.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
Useful client-side checks include:
|
||||||
|
|
||||||
|
``` powershell
|
||||||
|
gpupdate /force
|
||||||
|
gpresult /h C:\Temp\gpresult.html
|
||||||
|
Get-Service WinRM
|
||||||
|
winrm enumerate winrm/config/listener
|
||||||
|
Get-ScheduledTask -TaskName "bitformer - Configure Ansible WinRM"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also inspect Group Policy operational logs and Task Scheduler events
|
||||||
|
when Group Policy Preferences reports a task import failure.
|
||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
# AIM Installation Guide
|
||||||
|
|
||||||
|
This guide installs AIM with `/etc/ansible/scripts` as the project root.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/scripts/
|
||||||
|
├── pyproject.toml
|
||||||
|
└── src/
|
||||||
|
└── aim/
|
||||||
|
|
||||||
|
/etc/ansible/.venv/
|
||||||
|
/usr/local/bin/aim -> /etc/ansible/.venv/bin/aim
|
||||||
|
```
|
||||||
|
|
||||||
|
The virtual environment is intentionally not stored in Git.
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
On Debian/Ubuntu:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3 python3-venv python3-pip
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Rebuild the virtual environment
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo rm -rf /etc/ansible/.venv
|
||||||
|
sudo python3 -m venv /etc/ansible/.venv
|
||||||
|
sudo /etc/ansible/.venv/bin/python -m pip install --upgrade pip setuptools wheel
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Install AIM
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo /etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
ls -l /etc/ansible/.venv/bin/aim
|
||||||
|
/etc/ansible/.venv/bin/python -m pip check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Restore the system-wide command
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo ln -sfn /etc/ansible/.venv/bin/aim /usr/local/bin/aim
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
ls -l /usr/local/bin/aim
|
||||||
|
aim --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Authorization group / using another GID
|
||||||
|
|
||||||
|
AIM authorization is group-based rather than root-based. The default
|
||||||
|
required group is:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
srv_debsansible01_admins@bitformer.lan
|
||||||
|
```
|
||||||
|
|
||||||
|
AIM resolves the configured **group name** through the operating
|
||||||
|
system's NSS layer (for example local groups, SSSD or winbind). The
|
||||||
|
operator must have that group active as a primary or supplementary
|
||||||
|
group.
|
||||||
|
|
||||||
|
Do not hard-code a numeric GID into AIM merely because a particular
|
||||||
|
server uses a different GID. Numeric GIDs can differ between systems;
|
||||||
|
configure the appropriate group name and let NSS resolve its GID.
|
||||||
|
|
||||||
|
Inspect the default group and its resolved GID:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
getent group 'srv_debsansible01_admins@bitformer.lan'
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the current user's active groups/GIDs:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
id
|
||||||
|
id -G
|
||||||
|
id -Gn
|
||||||
|
```
|
||||||
|
|
||||||
|
If another authorization group should be used, change AIM's **Required
|
||||||
|
Group** through:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
Global Config
|
||||||
|
└── Required Group
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, if the local/AD-backed group is:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
ansible_operators
|
||||||
|
```
|
||||||
|
|
||||||
|
verify it first:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
getent group ansible_operators
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure `ansible_operators` as AIM's Required Group. AIM will use
|
||||||
|
the GID returned by NSS for that group.
|
||||||
|
|
||||||
|
After adding a user to a group, the login session may need to be renewed
|
||||||
|
before the supplementary group becomes active. Verify with `id` before
|
||||||
|
troubleshooting AIM authorization.
|
||||||
|
|
||||||
|
### Same group name, different GID
|
||||||
|
|
||||||
|
This is supported. For example, one server may resolve:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
ansible_operators:x:1200:...
|
||||||
|
```
|
||||||
|
|
||||||
|
and another may resolve:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
ansible_operators:x:48001:...
|
||||||
|
```
|
||||||
|
|
||||||
|
AIM should be configured with `ansible_operators`, not `1200` or
|
||||||
|
`48001`.
|
||||||
|
|
||||||
|
### Different group name
|
||||||
|
|
||||||
|
Configure the alternative group name in AIM Global Config and confirm:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
getent group '<group-name>'
|
||||||
|
id
|
||||||
|
```
|
||||||
|
|
||||||
|
If `getent` cannot resolve the group, fix NSS/SSSD/winbind/local group
|
||||||
|
resolution first.
|
||||||
|
|
||||||
|
## 6. Git-ignored runtime data
|
||||||
|
|
||||||
|
Important ignored data includes:
|
||||||
|
|
||||||
|
``` gitignore
|
||||||
|
.vscode/*
|
||||||
|
.venv/*
|
||||||
|
.ansible/*
|
||||||
|
secure/*
|
||||||
|
secure/keys/*
|
||||||
|
vault.yml
|
||||||
|
.ssh
|
||||||
|
*.msi
|
||||||
|
*.deb
|
||||||
|
*.rpm
|
||||||
|
**/.hosts.tsv
|
||||||
|
**/hosts.yml.aim-session.bak
|
||||||
|
**/hosts.aim-session.bak.yml
|
||||||
|
*.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
For AIM inventory recovery, the critical persistent data is `vault.yml`
|
||||||
|
and inventory `.ssh/` content. Do not restore `.venv`; rebuild it.
|
||||||
|
|
||||||
|
## 7. Recover missing Vaults and SSH keys
|
||||||
|
|
||||||
|
Example restored backup:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/inventories_RESTORED_20260915_152127
|
||||||
|
```
|
||||||
|
|
||||||
|
Active inventories:
|
||||||
|
|
||||||
|
``` text
|
||||||
|
/etc/ansible/inventories
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dry run
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
BACKUP="/etc/ansible/inventories_RESTORED_20260915_152127"
|
||||||
|
TARGET="/etc/ansible/inventories"
|
||||||
|
|
||||||
|
echo "=== vault.yml ==="
|
||||||
|
sudo find "$BACKUP" -type f -name 'vault.yml' -print0 |
|
||||||
|
while IFS= read -r -d '' src; do
|
||||||
|
rel="${src#"$BACKUP"/}"
|
||||||
|
dst="$TARGET/$rel"
|
||||||
|
if [ -e "$dst" ]; then
|
||||||
|
echo "KEEP $dst"
|
||||||
|
else
|
||||||
|
echo "RESTORE $src -> $dst"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== .ssh files ==="
|
||||||
|
sudo find "$BACKUP" -path '*/.ssh/*' -type f -print0 |
|
||||||
|
while IFS= read -r -d '' src; do
|
||||||
|
rel="${src#"$BACKUP"/}"
|
||||||
|
dst="$TARGET/$rel"
|
||||||
|
if [ -e "$dst" ]; then
|
||||||
|
echo "KEEP $dst"
|
||||||
|
else
|
||||||
|
echo "RESTORE $src -> $dst"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
BACKUP="/etc/ansible/inventories_RESTORED_20260915_152127"
|
||||||
|
TARGET="/etc/ansible/inventories"
|
||||||
|
|
||||||
|
sudo find "$BACKUP" -type f -name 'vault.yml' -print0 |
|
||||||
|
while IFS= read -r -d '' src; do
|
||||||
|
rel="${src#"$BACKUP"/}"
|
||||||
|
dst="$TARGET/$rel"
|
||||||
|
if [ -e "$dst" ]; then
|
||||||
|
echo "KEEP $dst"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
sudo mkdir -p "$(dirname "$dst")"
|
||||||
|
sudo cp -a "$src" "$dst"
|
||||||
|
echo "RESTORED $dst"
|
||||||
|
done
|
||||||
|
|
||||||
|
sudo find "$BACKUP" -path '*/.ssh/*' -type f -print0 |
|
||||||
|
while IFS= read -r -d '' src; do
|
||||||
|
rel="${src#"$BACKUP"/}"
|
||||||
|
dst="$TARGET/$rel"
|
||||||
|
if [ -e "$dst" ]; then
|
||||||
|
echo "KEEP $dst"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
sudo mkdir -p "$(dirname "$dst")"
|
||||||
|
sudo cp -a "$src" "$dst"
|
||||||
|
echo "RESTORED $dst"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
`cp -a` preserves ownership, permissions and timestamps. Existing active
|
||||||
|
files are never overwritten.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo find /etc/ansible/inventories -type f -name 'vault.yml' -print | sort
|
||||||
|
sudo find /etc/ansible/inventories -path '*/.ssh/*' -type f -print | sort
|
||||||
|
sudo find /etc/ansible/inventories -path '*/.ssh/*' -type f -exec ls -l {} \;
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the restored backup until Vault and SSH access have been tested.
|
||||||
|
|
||||||
|
## 8. Final verification
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
aim --version
|
||||||
|
/etc/ansible/.venv/bin/python -m pip check
|
||||||
|
aim
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating AIM
|
||||||
|
|
||||||
|
AIM is installed editable from `/etc/ansible/scripts`.
|
||||||
|
|
||||||
|
If its entry point needs recreation:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo /etc/ansible/.venv/bin/python -m pip install -e /etc/ansible/scripts
|
||||||
|
sudo ln -sfn /etc/ansible/.venv/bin/aim /usr/local/bin/aim
|
||||||
|
```
|
||||||
|
|
||||||
|
Customer data under `/etc/ansible/inventories` is separate from the AIM
|
||||||
|
source installation.
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "2.1.2"
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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."""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Sophos XGS host configuration helpers."""
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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'))
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Windows WinRM access management."""
|
||||||
@@ -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 "$escapedScript"</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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user