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:
2025-12-02 11:54:55 +01:00
parent 5320a5d20c
commit c0e26275f8
22 changed files with 1566 additions and 186 deletions

View File

@@ -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
View 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()