mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-12-03 16:09:29 +00:00
Refactor defaults generation, credential creation, and inventory management
### Overview This commit introduces a broad set of improvements across the defaults generator, credential creation subsystem, inventory creation workflow, and InventoryManager core logic. ### Major Changes - Support empty or config/main.yml in defaults generator and ensure that applications with empty configs are still included in defaults_applications. - Add '--snippet' and '--allow-empty-plain' modes to create/credentials.py with non-destructive merging and correct plain-secret handling. - Ensure empty strings for 'plain' credentials are never encrypted. - Update InventoryManager to fully support allow_empty_plain and prevent accidental overwriting or encrypting existing VaultScalar or dict values. - Add full-size implementation of cli/create/inventory.py including dynamic inventory building, role filtering, host_vars management, and parallelised credential snippet generation. - Fix schemas (Magento, Nextcloud, OAuth2-Proxy, keyboard-color, etc.) to align with the new credential model and avoid test failures. - Improve get_app_conf consistency by ensuring credentials.* paths are always resolvable for applications even when config/main.yml is empty. ### Added Test Coverage - Unit tests for defaults generator handling empty configs. - Full test suite for create/inventory.py including merge logic and vault-safe host_vars loading. - Extensive tests for InventoryManager: plain-secret behavior, vault handling, and recursion logic. - Update or remove outdated tests referencing old schema behaviour. ### Context This commit is associated with a refactoring and debugging session documented here: https://chatgpt.com/share/692ec0e1-5018-800f-b568-d09a53e9d0ee
This commit is contained in:
@@ -9,6 +9,14 @@ Usage example:
|
||||
--inventory-file host_vars/echoserver.yml \
|
||||
--vault-password-file .pass/echoserver.txt \
|
||||
--set credentials.database_password=mysecret
|
||||
|
||||
With snippet mode (no file changes, just YAML output):
|
||||
|
||||
infinito create credentials \
|
||||
--role-path roles/web-app-akaunting \
|
||||
--inventory-file host_vars/echoserver.yml \
|
||||
--vault-password-file .pass/echoserver.txt \
|
||||
--snippet
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -92,7 +100,14 @@ def to_vault_block(vault_handler: VaultHandler, value: Union[str, Any], label: s
|
||||
Return a ruamel scalar tagged as !vault. If the input value is already
|
||||
vault-encrypted (string contains $ANSIBLE_VAULT or is a !vault scalar), reuse/wrap.
|
||||
Otherwise, encrypt plaintext via ansible-vault.
|
||||
|
||||
Special rule:
|
||||
- Empty strings ("") are NOT encrypted and are returned as plain "".
|
||||
"""
|
||||
# Empty strings should not be encrypted
|
||||
if isinstance(value, str) and value == "":
|
||||
return ""
|
||||
|
||||
# Already a ruamel !vault scalar → reuse
|
||||
if _is_ruamel_vault(value):
|
||||
return value
|
||||
@@ -105,7 +120,6 @@ def to_vault_block(vault_handler: VaultHandler, value: Union[str, Any], label: s
|
||||
snippet = vault_handler.encrypt_string(str(value), label)
|
||||
return _make_vault_scalar_from_text(snippet)
|
||||
|
||||
|
||||
def parse_overrides(pairs: list[str]) -> Dict[str, str]:
|
||||
"""
|
||||
Parse --set key=value pairs into a dict.
|
||||
@@ -139,6 +153,23 @@ def main() -> int:
|
||||
"-y", "--yes", action="store_true",
|
||||
help="Non-interactive: assume 'yes' for all overwrite confirmations when --force is used"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--snippet",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Do not modify the inventory file. Instead, print a YAML snippet with "
|
||||
"the generated credentials to stdout. The snippet contains only the "
|
||||
"application's credentials (and ansible_become_password if provided)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-empty-plain",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Allow 'plain' credentials in the schema without an explicit --set override. "
|
||||
"Missing plain values will be set to an empty string before encryption."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
overrides = parse_overrides(args.set)
|
||||
@@ -148,36 +179,82 @@ def main() -> int:
|
||||
role_path=Path(args.role_path),
|
||||
inventory_path=Path(args.inventory_file),
|
||||
vault_pw=args.vault_password_file,
|
||||
overrides=overrides
|
||||
overrides=overrides,
|
||||
allow_empty_plain=args.allow_empty_plain,
|
||||
)
|
||||
|
||||
# 1) Load existing inventory with ruamel (round-trip)
|
||||
yaml_rt = YAML(typ="rt")
|
||||
yaml_rt.preserve_quotes = True
|
||||
|
||||
# Get schema-applied structure (defaults etc.) for *non-destructive* merge
|
||||
schema_inventory: Dict[str, Any] = manager.apply_schema()
|
||||
schema_apps = schema_inventory.get("applications", {})
|
||||
schema_app_block = schema_apps.get(manager.app_id, {})
|
||||
schema_creds = schema_app_block.get("credentials", {}) if isinstance(schema_app_block, dict) else {}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SNIPPET MODE: only build a YAML fragment and print to stdout, no file I/O
|
||||
# -------------------------------------------------------------------------
|
||||
if args.snippet:
|
||||
# Build a minimal structure:
|
||||
# applications:
|
||||
# <app_id>:
|
||||
# credentials:
|
||||
# key: !vault |
|
||||
# ...
|
||||
# ansible_become_password: !vault | ...
|
||||
snippet_data = CommentedMap()
|
||||
apps_snip = ensure_map(snippet_data, "applications")
|
||||
app_block_snip = ensure_map(apps_snip, manager.app_id)
|
||||
creds_snip = ensure_map(app_block_snip, "credentials")
|
||||
|
||||
for key, default_val in schema_creds.items():
|
||||
# Priority: --set exact key → default from schema → empty string
|
||||
ov = overrides.get(f"credentials.{key}", None)
|
||||
if ov is None:
|
||||
ov = overrides.get(key, None)
|
||||
|
||||
if ov is not None:
|
||||
value_for_key: Union[str, Any] = ov
|
||||
else:
|
||||
if _is_vault_encrypted(default_val):
|
||||
creds_snip[key] = to_vault_block(manager.vault_handler, default_val, key)
|
||||
continue
|
||||
value_for_key = "" if default_val is None else str(default_val)
|
||||
|
||||
creds_snip[key] = to_vault_block(manager.vault_handler, value_for_key, key)
|
||||
|
||||
# Optional ansible_become_password only if provided via overrides
|
||||
if "ansible_become_password" in overrides:
|
||||
snippet_data["ansible_become_password"] = to_vault_block(
|
||||
manager.vault_handler,
|
||||
overrides["ansible_become_password"],
|
||||
"ansible_become_password",
|
||||
)
|
||||
|
||||
yaml_rt.dump(snippet_data, sys.stdout)
|
||||
return 0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# DEFAULT MODE: modify the inventory file on disk (previous behavior)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# 1) Load existing inventory with ruamel (round-trip)
|
||||
with open(args.inventory_file, "r", encoding="utf-8") as f:
|
||||
data = yaml_rt.load(f) # CommentedMap or None
|
||||
if data is None:
|
||||
data = CommentedMap()
|
||||
|
||||
# 2) Get schema-applied structure (defaults etc.) for *non-destructive* merge
|
||||
schema_inventory: Dict[str, Any] = manager.apply_schema()
|
||||
|
||||
# 3) Ensure structural path exists
|
||||
# 2) Ensure structural path exists
|
||||
apps = ensure_map(data, "applications")
|
||||
app_block = ensure_map(apps, manager.app_id)
|
||||
creds = ensure_map(app_block, "credentials")
|
||||
|
||||
# 4) Determine defaults we could add
|
||||
schema_apps = schema_inventory.get("applications", {})
|
||||
schema_app_block = schema_apps.get(manager.app_id, {})
|
||||
schema_creds = schema_app_block.get("credentials", {}) if isinstance(schema_app_block, dict) else {}
|
||||
|
||||
# 5) Add ONLY missing credential keys
|
||||
# 3) Add ONLY missing credential keys (respect existing values)
|
||||
newly_added_keys = set()
|
||||
for key, default_val in schema_creds.items():
|
||||
if key in creds:
|
||||
# existing → do not touch (preserve plaintext/vault/formatting/comments)
|
||||
# Existing → do not touch (preserve plaintext/vault/formatting/comments)
|
||||
continue
|
||||
|
||||
# Value to use for the new key
|
||||
@@ -200,7 +277,7 @@ def main() -> int:
|
||||
creds[key] = to_vault_block(manager.vault_handler, value_for_new_key, key)
|
||||
newly_added_keys.add(key)
|
||||
|
||||
# 6) ansible_become_password: only add if missing;
|
||||
# 4) ansible_become_password: only add if missing;
|
||||
# never rewrite an existing one unless --force (+ confirm/--yes) and override provided.
|
||||
if "ansible_become_password" not in data:
|
||||
val = overrides.get("ansible_become_password", None)
|
||||
@@ -216,7 +293,7 @@ def main() -> int:
|
||||
manager.vault_handler, overrides["ansible_become_password"], "ansible_become_password"
|
||||
)
|
||||
|
||||
# 7) Overrides for existing credential keys (only with --force)
|
||||
# 5) Overrides for existing credential keys (only with --force)
|
||||
if args.force:
|
||||
for ov_key, ov_val in overrides.items():
|
||||
# Accept both 'credentials.key' and bare 'key'
|
||||
@@ -228,7 +305,7 @@ def main() -> int:
|
||||
if args.yes or ask_for_confirmation(key):
|
||||
creds[key] = to_vault_block(manager.vault_handler, ov_val, key)
|
||||
|
||||
# 8) Write back with ruamel (preserve formatting & comments)
|
||||
# 6) Write back with ruamel (preserve formatting & comments)
|
||||
with open(args.inventory_file, "w", encoding="utf-8") as f:
|
||||
yaml_rt.dump(data, f)
|
||||
|
||||
|
||||
695
cli/create/inventory.py
Normal file
695
cli/create/inventory.py
Normal file
@@ -0,0 +1,695 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Create or update a full Ansible inventory for a single host and automatically
|
||||
generate credentials for all selected applications.
|
||||
|
||||
This subcommand:
|
||||
|
||||
1. Uses `build inventory full` to generate a dynamic inventory for the given
|
||||
host containing all invokable applications.
|
||||
2. Optionally filters the resulting groups by a user-provided list of
|
||||
application_ids (`--roles`).
|
||||
3. Merges the generated inventory into an existing inventory file, without
|
||||
deleting or overwriting unrelated entries.
|
||||
4. Ensures `host_vars/<host>.yml` exists and stores base settings such as:
|
||||
- PRIMARY_DOMAIN
|
||||
- WEB_PROTOCOL
|
||||
Existing keys are preserved (only missing keys are added).
|
||||
5. For every application_id in the final inventory, uses:
|
||||
- `meta/applications/role_name.py` to resolve the role path
|
||||
- `create/credentials.py --snippet` to generate credentials YAML
|
||||
snippets, and merges all snippets into host_vars in a single write.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Set, Optional
|
||||
import concurrent.futures
|
||||
import os
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
raise SystemExit("Please `pip install pyyaml` to use `infinito create inventory`.")
|
||||
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
except ImportError: # pragma: no cover
|
||||
raise SystemExit("Please `pip install ruamel.yaml` to use `infinito create inventory`.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_subprocess(
|
||||
cmd: List[str],
|
||||
capture_output: bool = False,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Run a subprocess command and either stream output or capture it.
|
||||
Raise SystemExit on non-zero return code.
|
||||
"""
|
||||
if capture_output:
|
||||
result = subprocess.run(cmd, text=True, capture_output=True, env=env)
|
||||
else:
|
||||
result = subprocess.run(cmd, text=True, env=env)
|
||||
if result.returncode != 0:
|
||||
msg = f"Command failed: {' '.join(str(c) for c in cmd)}\n"
|
||||
if capture_output:
|
||||
msg += f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}\n"
|
||||
raise SystemExit(msg)
|
||||
return result
|
||||
|
||||
def build_env_with_project_root(project_root: Path) -> Dict[str, str]:
|
||||
"""
|
||||
Return an environment dict where PYTHONPATH includes the project root.
|
||||
This makes `module_utils` and other top-level packages importable when
|
||||
running project scripts as subprocesses.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
root_str = str(project_root)
|
||||
existing = env.get("PYTHONPATH")
|
||||
if existing:
|
||||
if root_str not in existing.split(os.pathsep):
|
||||
env["PYTHONPATH"] = root_str + os.pathsep + existing
|
||||
else:
|
||||
env["PYTHONPATH"] = root_str
|
||||
return env
|
||||
|
||||
def detect_project_root() -> Path:
|
||||
"""
|
||||
Detect project root assuming this file is at: <root>/cli/create/inventory.py
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
# .../repo/cli/create/inventory.py → parents[2] == repo
|
||||
return here.parents[2]
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"Expected a mapping at top-level in {path}, got {type(data)}")
|
||||
return data
|
||||
|
||||
|
||||
def dump_yaml(path: Path, data: Dict[str, Any]) -> None:
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
|
||||
|
||||
|
||||
def parse_roles_list(raw_roles: Optional[List[str]]) -> Optional[Set[str]]:
|
||||
"""
|
||||
Parse a list of roles supplied on the CLI. Supports:
|
||||
--roles web-app-nextcloud web-app-mastodon
|
||||
--roles web-app-nextcloud,web-app-mastodon
|
||||
"""
|
||||
if not raw_roles:
|
||||
return None
|
||||
result: Set[str] = set()
|
||||
for token in raw_roles:
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
# Allow comma-separated tokens as well
|
||||
for part in token.split(","):
|
||||
part = part.strip()
|
||||
if part:
|
||||
result.add(part)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inventory generation (servers.yml via build/inventory/full.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_dynamic_inventory(
|
||||
host: str,
|
||||
roles_dir: Path,
|
||||
categories_file: Path,
|
||||
tmp_inventory: Path,
|
||||
project_root: Path,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Call `cli/build/inventory/full.py` directly to generate a dynamic inventory
|
||||
YAML for the given host and return it as a Python dict.
|
||||
"""
|
||||
script = project_root / "cli" / "build" / "inventory" / "full.py"
|
||||
env = build_env_with_project_root(project_root)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--host", host,
|
||||
"--format", "yaml",
|
||||
"--inventory-style", "group",
|
||||
"-c", str(categories_file),
|
||||
"-r", str(roles_dir),
|
||||
"-o", str(tmp_inventory),
|
||||
]
|
||||
run_subprocess(cmd, capture_output=False, env=env)
|
||||
data = load_yaml(tmp_inventory)
|
||||
tmp_inventory.unlink(missing_ok=True)
|
||||
return data
|
||||
|
||||
def filter_inventory_by_roles(inv_data: Dict[str, Any], roles_filter: Set[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Return a new inventory dict that contains only the groups whose names
|
||||
are in `roles_filter`. All other structure is preserved.
|
||||
"""
|
||||
all_block = inv_data.get("all", {})
|
||||
children = all_block.get("children", {}) or {}
|
||||
|
||||
filtered_children: Dict[str, Any] = {}
|
||||
for group_name, group_data in children.items():
|
||||
if group_name in roles_filter:
|
||||
filtered_children[group_name] = group_data
|
||||
|
||||
new_all = dict(all_block)
|
||||
new_all["children"] = filtered_children
|
||||
return {"all": new_all}
|
||||
|
||||
|
||||
def merge_inventories(
|
||||
base: Dict[str, Any],
|
||||
new: Dict[str, Any],
|
||||
host: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge `new` inventory into `base` inventory without deleting any
|
||||
existing groups/hosts/vars.
|
||||
|
||||
For each group in `new`:
|
||||
- ensure the group exists in `base`
|
||||
- ensure `hosts` exists
|
||||
- ensure the given `host` is present in that group's `hosts`
|
||||
(keep existing hosts and host vars untouched)
|
||||
"""
|
||||
base_all = base.setdefault("all", {})
|
||||
base_children = base_all.setdefault("children", {})
|
||||
|
||||
new_all = new.get("all", {})
|
||||
new_children = new_all.get("children", {}) or {}
|
||||
|
||||
for group_name, group_data in new_children.items():
|
||||
# Ensure group exists in base
|
||||
base_group = base_children.setdefault(group_name, {})
|
||||
base_hosts = base_group.setdefault("hosts", {})
|
||||
|
||||
# Try to propagate host vars from new inventory if they exist
|
||||
new_hosts = (group_data or {}).get("hosts", {}) or {}
|
||||
host_vars = {}
|
||||
if isinstance(new_hosts, dict) and host in new_hosts:
|
||||
host_vars = new_hosts.get(host) or {}
|
||||
|
||||
# Ensure the target host exists in this group
|
||||
if host not in base_hosts:
|
||||
base_hosts[host] = host_vars or {}
|
||||
|
||||
return base
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# host_vars helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_host_vars_file(
|
||||
host_vars_file: Path,
|
||||
host: str,
|
||||
primary_domain: str,
|
||||
web_protocol: str,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure host_vars/<host>.yml exists and contains base settings.
|
||||
|
||||
Important: Existing keys are NOT overwritten. Only missing keys are added:
|
||||
- PRIMARY_DOMAIN
|
||||
- WEB_PROTOCOL
|
||||
|
||||
Uses ruamel.yaml so that custom tags like !vault are preserved and do not
|
||||
break parsing (unlike PyYAML safe_load).
|
||||
"""
|
||||
yaml_rt = YAML(typ="rt")
|
||||
yaml_rt.preserve_quotes = True
|
||||
|
||||
if host_vars_file.exists():
|
||||
with host_vars_file.open("r", encoding="utf-8") as f:
|
||||
data = yaml_rt.load(f)
|
||||
if data is None:
|
||||
data = CommentedMap()
|
||||
else:
|
||||
data = CommentedMap()
|
||||
|
||||
if not isinstance(data, CommentedMap):
|
||||
tmp = CommentedMap()
|
||||
for k, v in dict(data).items():
|
||||
tmp[k] = v
|
||||
data = tmp
|
||||
|
||||
# Only set defaults; do NOT override existing values
|
||||
if "PRIMARY_DOMAIN" not in data:
|
||||
data["PRIMARY_DOMAIN"] = primary_domain
|
||||
if "WEB_PROTOCOL" not in data:
|
||||
data["WEB_PROTOCOL"] = web_protocol
|
||||
|
||||
host_vars_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with host_vars_file.open("w", encoding="utf-8") as f:
|
||||
yaml_rt.dump(data, f)
|
||||
|
||||
def ensure_ruamel_map(node: CommentedMap, key: str) -> CommentedMap:
|
||||
"""
|
||||
Ensure node[key] exists and is a mapping (CommentedMap).
|
||||
"""
|
||||
if key not in node or not isinstance(node.get(key), CommentedMap):
|
||||
node[key] = CommentedMap()
|
||||
return node[key]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role resolution (meta/applications/role_name.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_role_path(
|
||||
application_id: str,
|
||||
roles_dir: Path,
|
||||
project_root: Path,
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Use `cli/meta/applications/role_name.py` to resolve the role path
|
||||
for a given application_id. Returns an absolute Path or None on failure.
|
||||
|
||||
We expect the helper to print either:
|
||||
- a bare role folder name (e.g. 'web-app-nextcloud'), or
|
||||
- a relative path like 'roles/web-app-nextcloud', or
|
||||
- an absolute path.
|
||||
|
||||
We try, in order:
|
||||
1) <roles_dir>/<printed>
|
||||
2) <project_root>/<printed>
|
||||
3) use printed as-is if absolute
|
||||
"""
|
||||
script = project_root / "cli" / "meta" / "applications" / "role_name.py"
|
||||
env = build_env_with_project_root(project_root)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script),
|
||||
application_id,
|
||||
"-r", str(roles_dir),
|
||||
]
|
||||
result = run_subprocess(cmd, capture_output=True, env=env)
|
||||
raw = (result.stdout or "").strip()
|
||||
|
||||
if not raw:
|
||||
print(f"[WARN] Could not resolve role for application_id '{application_id}'. Skipping.", file=sys.stderr)
|
||||
return None
|
||||
|
||||
printed = Path(raw)
|
||||
|
||||
# 1) If it's absolute, just use it
|
||||
if printed.is_absolute():
|
||||
role_path = printed
|
||||
else:
|
||||
# 2) Prefer resolving below roles_dir
|
||||
candidate = roles_dir / printed
|
||||
if candidate.exists():
|
||||
role_path = candidate
|
||||
else:
|
||||
# 3) Fallback: maybe the helper already printed something like 'roles/web-app-nextcloud'
|
||||
candidate2 = project_root / printed
|
||||
if candidate2.exists():
|
||||
role_path = candidate2
|
||||
else:
|
||||
print(
|
||||
f"[WARN] Resolved role path does not exist after probing: "
|
||||
f"{candidate} and {candidate2} (application_id={application_id})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if not role_path.exists():
|
||||
print(f"[WARN] Resolved role path does not exist: {role_path} (application_id={application_id})", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return role_path
|
||||
|
||||
def fatal(msg: str) -> "NoReturn":
|
||||
"""Print a fatal error and exit with code 1."""
|
||||
sys.stderr.write(f"[FATAL] {msg}\n")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credentials generation via create/credentials.py --snippet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _generate_credentials_snippet_for_app(
|
||||
app_id: str,
|
||||
roles_dir: Path,
|
||||
host_vars_file: Path,
|
||||
vault_password_file: Path,
|
||||
project_root: Path,
|
||||
credentials_script: Path,
|
||||
) -> Optional[CommentedMap]:
|
||||
"""
|
||||
Worker function for a single application_id:
|
||||
|
||||
1. Resolve role path via meta/applications/role_name.py.
|
||||
2. Skip if role path cannot be resolved.
|
||||
3. Skip if schema/main.yml does not exist.
|
||||
4. Call create/credentials.py with --snippet to get a YAML fragment.
|
||||
|
||||
Returns a ruamel CommentedMap (snippet) or None on failure.
|
||||
Errors are logged but do NOT abort the whole run.
|
||||
"""
|
||||
try:
|
||||
role_path = resolve_role_path(app_id, roles_dir, project_root)
|
||||
except SystemExit as exc:
|
||||
sys.stderr.write(f"[ERROR] Failed to resolve role for {app_id}: {exc}\n")
|
||||
return None
|
||||
except Exception as exc: # pragma: no cover
|
||||
sys.stderr.write(
|
||||
f"[ERROR] Unexpected error while resolving role for {app_id}: {exc}\n"
|
||||
)
|
||||
return None
|
||||
|
||||
if role_path is None:
|
||||
# resolve_role_path already logged a warning
|
||||
return None
|
||||
|
||||
schema_path = role_path / "schema" / "main.yml"
|
||||
if not schema_path.exists():
|
||||
print(
|
||||
f"[INFO] Skipping {app_id}: no schema/main.yml found at {schema_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(credentials_script),
|
||||
"--role-path", str(role_path),
|
||||
"--inventory-file", str(host_vars_file),
|
||||
"--vault-password-file", str(vault_password_file),
|
||||
"--snippet",
|
||||
"--allow-empty-plain",
|
||||
]
|
||||
print(f"[INFO] Generating credentials snippet for {app_id} (role: {role_path})")
|
||||
|
||||
env = build_env_with_project_root(project_root)
|
||||
result = subprocess.run(cmd, text=True, capture_output=True, env=env)
|
||||
if result.returncode != 0:
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
fatal(
|
||||
f"Command failed ({result.returncode}): {' '.join(map(str, cmd))}\n"
|
||||
f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"
|
||||
)
|
||||
|
||||
snippet_text = (result.stdout or "").strip()
|
||||
if not snippet_text:
|
||||
# No output means nothing to merge
|
||||
return None
|
||||
|
||||
yaml_rt = YAML(typ="rt")
|
||||
try:
|
||||
data = yaml_rt.load(snippet_text)
|
||||
except Exception as exc: # pragma: no cover
|
||||
sys.stderr.write(
|
||||
f"[ERROR] Failed to parse credentials snippet for {app_id}: {exc}\n"
|
||||
f"Snippet was:\n{snippet_text}\n"
|
||||
)
|
||||
return None
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
if not isinstance(data, CommentedMap):
|
||||
# Normalize to CommentedMap
|
||||
cm = CommentedMap()
|
||||
for k, v in dict(data).items():
|
||||
cm[k] = v
|
||||
return cm
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def generate_credentials_for_roles(
|
||||
application_ids: List[str],
|
||||
roles_dir: Path,
|
||||
host_vars_file: Path,
|
||||
vault_password_file: Path,
|
||||
project_root: Path,
|
||||
workers: int = 4,
|
||||
) -> None:
|
||||
"""
|
||||
Generate credentials for all given application_ids using create/credentials.py --snippet.
|
||||
|
||||
Steps:
|
||||
1) In parallel, for each app_id:
|
||||
- resolve role path
|
||||
- skip roles without schema/main.yml
|
||||
- run create/credentials.py --snippet
|
||||
- return a YAML snippet (ruamel CommentedMap)
|
||||
2) Sequentially, merge all snippets into host_vars/<host>.yml in a single write:
|
||||
- applications.<app_id>.credentials.<key> is added only if missing
|
||||
- ansible_become_password is added only if missing
|
||||
"""
|
||||
if not application_ids:
|
||||
print("[WARN] No application_ids to process for credential generation.", file=sys.stderr)
|
||||
return
|
||||
|
||||
credentials_script = project_root / "cli" / "create" / "credentials.py"
|
||||
max_workers = max(1, workers)
|
||||
print(
|
||||
f"[INFO] Running credentials snippet generation for {len(application_ids)} "
|
||||
f"applications with {max_workers} worker threads..."
|
||||
)
|
||||
|
||||
snippets: List[CommentedMap] = []
|
||||
|
||||
# 1) Parallel: collect snippets
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_app: Dict[concurrent.futures.Future, str] = {}
|
||||
|
||||
for app_id in application_ids:
|
||||
future = executor.submit(
|
||||
_generate_credentials_snippet_for_app,
|
||||
app_id,
|
||||
roles_dir,
|
||||
host_vars_file,
|
||||
vault_password_file,
|
||||
project_root,
|
||||
credentials_script,
|
||||
)
|
||||
future_to_app[future] = app_id
|
||||
|
||||
for future in concurrent.futures.as_completed(future_to_app):
|
||||
app_id = future_to_app[future]
|
||||
try:
|
||||
snippet = future.result()
|
||||
except Exception as exc:
|
||||
fatal(f"Worker for {app_id} failed with exception: {exc}")
|
||||
|
||||
if snippet is not None:
|
||||
snippets.append(snippet)
|
||||
|
||||
if not snippets:
|
||||
print("[WARN] No credentials snippets were generated.", file=sys.stderr)
|
||||
return
|
||||
|
||||
# 2) Sequential: merge snippets into host_vars
|
||||
yaml_rt = YAML(typ="rt")
|
||||
yaml_rt.preserve_quotes = True
|
||||
|
||||
if host_vars_file.exists():
|
||||
with host_vars_file.open("r", encoding="utf-8") as f:
|
||||
doc = yaml_rt.load(f)
|
||||
if doc is None:
|
||||
doc = CommentedMap()
|
||||
else:
|
||||
doc = CommentedMap()
|
||||
|
||||
if not isinstance(doc, CommentedMap):
|
||||
tmp = CommentedMap()
|
||||
for k, v in dict(doc).items():
|
||||
tmp[k] = v
|
||||
doc = tmp
|
||||
|
||||
# Merge each snippet
|
||||
for snippet in snippets:
|
||||
apps_snip = snippet.get("applications", {}) or {}
|
||||
if isinstance(apps_snip, dict):
|
||||
apps_doc = ensure_ruamel_map(doc, "applications")
|
||||
for app_id, app_block_snip in apps_snip.items():
|
||||
if not isinstance(app_block_snip, dict):
|
||||
continue
|
||||
app_doc = ensure_ruamel_map(apps_doc, app_id)
|
||||
creds_doc = ensure_ruamel_map(app_doc, "credentials")
|
||||
|
||||
creds_snip = app_block_snip.get("credentials", {}) or {}
|
||||
if not isinstance(creds_snip, dict):
|
||||
continue
|
||||
|
||||
for key, val in creds_snip.items():
|
||||
# Only add missing keys; do not overwrite existing credentials
|
||||
if key not in creds_doc:
|
||||
creds_doc[key] = val
|
||||
|
||||
# ansible_become_password: only add if missing
|
||||
if "ansible_become_password" in snippet and "ansible_become_password" not in doc:
|
||||
doc["ansible_become_password"] = snippet["ansible_become_password"]
|
||||
|
||||
with host_vars_file.open("w", encoding="utf-8") as f:
|
||||
yaml_rt.dump(doc, f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Create or update a full inventory for a host and generate "
|
||||
"credentials for all selected applications."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
required=True,
|
||||
help="Hostname to use in the inventory (e.g. galaxyserver, localhost).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--primary-domain",
|
||||
required=True,
|
||||
help="Primary domain for this host (e.g. infinito.nexus).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--web-protocol",
|
||||
default="https",
|
||||
choices=("http", "https"),
|
||||
help="Web protocol to use for this host (default: https).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inventory-dir",
|
||||
required=True,
|
||||
help="Path to the inventory directory (e.g. inventories/galaxyserver).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inventory-file",
|
||||
help="Inventory YAML file path (default: <inventory-dir>/servers.yml).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--roles",
|
||||
nargs="+",
|
||||
help=(
|
||||
"Optional list of application_ids to include. "
|
||||
"If omitted, all invokable applications are used. "
|
||||
"Supports comma-separated values as well."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vault-password-file",
|
||||
required=True,
|
||||
help="Path to the Vault password file for credentials generation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--roles-dir",
|
||||
help="Path to the roles/ directory (default: <project-root>/roles).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--categories-file",
|
||||
help="Path to roles/categories.yml (default: <roles-dir>/categories.yml).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Number of worker threads for parallel credentials snippet generation (default: 4).",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
project_root = detect_project_root()
|
||||
roles_dir = Path(args.roles_dir) if args.roles_dir else (project_root / "roles")
|
||||
categories_file = Path(args.categories_file) if args.categories_file else (roles_dir / "categories.yml")
|
||||
|
||||
inventory_dir = Path(args.inventory_dir).resolve()
|
||||
inventory_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
inventory_file = Path(args.inventory_file) if args.inventory_file else (inventory_dir / "servers.yml")
|
||||
inventory_file = inventory_file.resolve()
|
||||
|
||||
host_vars_dir = inventory_dir / "host_vars"
|
||||
host_vars_file = host_vars_dir / f"{args.host}.yml"
|
||||
|
||||
vault_password_file = Path(args.vault_password_file).resolve()
|
||||
|
||||
roles_filter = parse_roles_list(args.roles)
|
||||
tmp_inventory = inventory_dir / "_inventory_full_tmp.yml"
|
||||
|
||||
# 1) Generate dynamic inventory via build/inventory/full.py
|
||||
print("[INFO] Generating dynamic inventory via cli/build/inventory/full.py ...")
|
||||
dyn_inv = generate_dynamic_inventory(
|
||||
host=args.host,
|
||||
roles_dir=roles_dir,
|
||||
categories_file=categories_file,
|
||||
tmp_inventory=tmp_inventory,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
# 2) Optional: filter by roles
|
||||
if roles_filter:
|
||||
print(f"[INFO] Filtering inventory to roles: {', '.join(sorted(roles_filter))}")
|
||||
dyn_inv = filter_inventory_by_roles(dyn_inv, roles_filter)
|
||||
|
||||
# Collect final application_ids from dynamic inventory for credential generation
|
||||
dyn_all = dyn_inv.get("all", {})
|
||||
dyn_children = dyn_all.get("children", {}) or {}
|
||||
application_ids = sorted(dyn_children.keys())
|
||||
|
||||
if not application_ids:
|
||||
print("[WARN] No application_ids found in dynamic inventory after filtering. Nothing to do.", file=sys.stderr)
|
||||
|
||||
# 3) Merge with existing inventory file (if any)
|
||||
if inventory_file.exists():
|
||||
print(f"[INFO] Merging into existing inventory: {inventory_file}")
|
||||
base_inv = load_yaml(inventory_file)
|
||||
else:
|
||||
print(f"[INFO] Creating new inventory file: {inventory_file}")
|
||||
base_inv = {}
|
||||
|
||||
merged_inv = merge_inventories(base_inv, dyn_inv, host=args.host)
|
||||
dump_yaml(inventory_file, merged_inv)
|
||||
|
||||
# 4) Ensure host_vars/<host>.yml exists and has base settings
|
||||
print(f"[INFO] Ensuring host_vars for host '{args.host}' at {host_vars_file}")
|
||||
ensure_host_vars_file(
|
||||
host_vars_file=host_vars_file,
|
||||
host=args.host,
|
||||
primary_domain=args.primary_domain,
|
||||
web_protocol=args.web_protocol,
|
||||
)
|
||||
|
||||
# 5) Generate credentials for all application_ids (snippets + single merge)
|
||||
if application_ids:
|
||||
print(f"[INFO] Generating credentials for {len(application_ids)} applications...")
|
||||
generate_credentials_for_roles(
|
||||
application_ids=application_ids,
|
||||
roles_dir=roles_dir,
|
||||
host_vars_file=host_vars_file,
|
||||
vault_password_file=vault_password_file,
|
||||
project_root=project_root,
|
||||
workers=args.workers,
|
||||
)
|
||||
|
||||
print("[INFO] Done. Inventory and host_vars updated without deleting existing values.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
Reference in New Issue
Block a user