mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-12-10 11:26:24 +00:00
Refactor deploy CLI into cli/deploy/dedicated.py, add package inits, and update unit tests (see ChatGPT conversation: https://chatgpt.com/share/6931af9e-dad0-800f-9a8f-6d01c373de87)
This commit is contained in:
0
cli/deploy/__init__.py
Normal file
0
cli/deploy/__init__.py
Normal file
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Infinito.Nexus deploy CLI
|
Infinito.Nexus Deploy CLI
|
||||||
|
|
||||||
This script is the main entrypoint for running the Ansible playbook with
|
This script is the main entrypoint for running the Ansible playbook with
|
||||||
dynamic MODE_* flags, automatic inventory validation, and optional build/test
|
dynamic MODE_* flags, automatic inventory validation, and optional build/test
|
||||||
steps.
|
phases. It supports partial deployments, dynamic MODE flag generation,
|
||||||
|
inventory validation, and structured execution flow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -18,6 +19,20 @@ import re
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# Path resolution
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Current file: .../cli/deploy/deploy.py
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) # → cli/deploy
|
||||||
|
CLI_ROOT = os.path.dirname(SCRIPT_DIR) # → cli
|
||||||
|
REPO_ROOT = os.path.dirname(CLI_ROOT) # → project root
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# Main execution logic
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
def run_ansible_playbook(
|
def run_ansible_playbook(
|
||||||
inventory: str,
|
inventory: str,
|
||||||
modes: Dict[str, Any],
|
modes: Dict[str, Any],
|
||||||
@@ -30,96 +45,97 @@ def run_ansible_playbook(
|
|||||||
logs: bool = False,
|
logs: bool = False,
|
||||||
diff: bool = False,
|
diff: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run ansible-playbook with the given parameters and modes."""
|
"""Run ansible-playbook with the given parameters and execution modes."""
|
||||||
start_time = datetime.datetime.now()
|
start_time = datetime.datetime.now()
|
||||||
print(f"\n▶️ Script started at: {start_time.isoformat()}\n")
|
print(f"\n▶️ Script started at: {start_time.isoformat()}\n")
|
||||||
|
|
||||||
# 1) Cleanup phase (MODE_CLEANUP)
|
# ---------------------------------------------------------
|
||||||
|
# 1) Cleanup Phase
|
||||||
|
# ---------------------------------------------------------
|
||||||
if modes.get("MODE_CLEANUP", False):
|
if modes.get("MODE_CLEANUP", False):
|
||||||
cleanup_command = ["make", "clean-keep-logs"] if logs else ["make", "clean"]
|
cleanup_cmd = ["make", "clean-keep-logs"] if logs else ["make", "clean"]
|
||||||
print(f"\n🧹 Cleaning up project ({' '.join(cleanup_command)})...\n")
|
print(f"\n🧹 Running cleanup ({' '.join(cleanup_cmd)})...\n")
|
||||||
subprocess.run(cleanup_command, check=True)
|
subprocess.run(cleanup_cmd, check=True)
|
||||||
else:
|
else:
|
||||||
print("\n🧹 Cleanup skipped (MODE_CLEANUP=false or not set)\n")
|
print("\n🧹 Cleanup skipped (MODE_CLEANUP not set or False)\n")
|
||||||
|
|
||||||
# 2) Build phase
|
# ---------------------------------------------------------
|
||||||
|
# 2) Build Phase
|
||||||
|
# ---------------------------------------------------------
|
||||||
if not skip_build:
|
if not skip_build:
|
||||||
print("\n🛠️ Building project (make messy-build)...\n")
|
print("\n🛠️ Running project build (make messy-build)...\n")
|
||||||
subprocess.run(["make", "messy-build"], check=True)
|
subprocess.run(["make", "messy-build"], check=True)
|
||||||
else:
|
else:
|
||||||
print("\n🛠️ Build skipped (--skip-build)\n")
|
print("\n🛠️ Build skipped (--skip-build)\n")
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
# The Ansible playbook is located in the repo root
|
||||||
repo_root = os.path.dirname(script_dir)
|
playbook_path = os.path.join(REPO_ROOT, "playbook.yml")
|
||||||
playbook = os.path.join(repo_root, "playbook.yml")
|
|
||||||
|
|
||||||
# 3) Inventory validation phase (MODE_ASSERT)
|
# ---------------------------------------------------------
|
||||||
|
# 3) Inventory Validation Phase
|
||||||
|
# ---------------------------------------------------------
|
||||||
if modes.get("MODE_ASSERT", None) is False:
|
if modes.get("MODE_ASSERT", None) is False:
|
||||||
print("\n🔍 Inventory assertion explicitly disabled (MODE_ASSERT=false)\n")
|
print("\n🔍 Inventory assertion explicitly disabled (MODE_ASSERT=false)\n")
|
||||||
elif "MODE_ASSERT" not in modes or modes["MODE_ASSERT"] is True:
|
else:
|
||||||
print("\n🔍 Validating inventory before deployment...\n")
|
print("\n🔍 Validating inventory before deployment...\n")
|
||||||
|
validator_path = os.path.join(CLI_ROOT, "validate", "inventory.py")
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[sys.executable, validator_path, os.path.dirname(inventory)],
|
||||||
sys.executable,
|
|
||||||
os.path.join(script_dir, "validate", "inventory.py"),
|
|
||||||
os.path.dirname(inventory),
|
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
print(
|
print(
|
||||||
"\n[ERROR] Inventory validation failed. Aborting deploy.\n",
|
"\n[ERROR] Inventory validation failed. Aborting deployment.\n",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 4) Test phase
|
# ---------------------------------------------------------
|
||||||
|
# 4) Test Phase
|
||||||
|
# ---------------------------------------------------------
|
||||||
if not skip_tests:
|
if not skip_tests:
|
||||||
print("\n🧪 Running tests (make messy-test)...\n")
|
print("\n🧪 Running tests (make messy-test)...\n")
|
||||||
subprocess.run(["make", "messy-test"], check=True)
|
subprocess.run(["make", "messy-test"], check=True)
|
||||||
else:
|
else:
|
||||||
print("\n🧪 Tests skipped (--skip-tests)\n")
|
print("\n🧪 Tests skipped (--skip-tests)\n")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
# 5) Build ansible-playbook command
|
# 5) Build ansible-playbook command
|
||||||
cmd: List[str] = ["ansible-playbook", "-i", inventory, playbook]
|
# ---------------------------------------------------------
|
||||||
|
cmd: List[str] = ["ansible-playbook", "-i", inventory, playbook_path]
|
||||||
|
|
||||||
# --limit / -l
|
# Limit hosts
|
||||||
if limit:
|
if limit:
|
||||||
cmd.extend(["-l", limit])
|
cmd.extend(["-l", limit])
|
||||||
|
|
||||||
# extra var: allowed_applications
|
# Allowed applications (partial deployment)
|
||||||
if allowed_applications:
|
if allowed_applications:
|
||||||
joined = ",".join(allowed_applications)
|
joined = ",".join(allowed_applications)
|
||||||
cmd.extend(["-e", f"allowed_applications={joined}"])
|
cmd.extend(["-e", f"allowed_applications={joined}"])
|
||||||
|
|
||||||
# inject MODE_* variables as extra vars
|
# MODE_* flags
|
||||||
for key, value in modes.items():
|
for key, value in modes.items():
|
||||||
val = str(value).lower() if isinstance(value, bool) else str(value)
|
val = str(value).lower() if isinstance(value, bool) else str(value)
|
||||||
cmd.extend(["-e", f"{key}={val}"])
|
cmd.extend(["-e", f"{key}={val}"])
|
||||||
|
|
||||||
# vault password handling
|
# Vault password file
|
||||||
if password_file:
|
if password_file:
|
||||||
# If a file is explicitly provided, pass it through
|
|
||||||
cmd.extend(["--vault-password-file", password_file])
|
cmd.extend(["--vault-password-file", password_file])
|
||||||
# else:
|
|
||||||
# No explicit vault option → ansible will prompt if it needs a password.
|
|
||||||
# This keeps the old behaviour and the CLI help text correct.
|
|
||||||
|
|
||||||
# diff mode
|
# Enable diff mode
|
||||||
if diff:
|
if diff:
|
||||||
cmd.append("--diff")
|
cmd.append("--diff")
|
||||||
|
|
||||||
# MODE_DEBUG=true → always at least -vvv
|
# MODE_DEBUG → enforce high verbosity
|
||||||
if modes.get("MODE_DEBUG", False):
|
if modes.get("MODE_DEBUG", False):
|
||||||
verbose = max(verbose, 3)
|
verbose = max(verbose, 3)
|
||||||
|
|
||||||
# verbosity flags
|
# Verbosity flags
|
||||||
if verbose:
|
if verbose:
|
||||||
cmd.append("-" + "v" * verbose)
|
cmd.append("-" + "v" * verbose)
|
||||||
|
|
||||||
print("\n🚀 Launching Ansible Playbook...\n")
|
print("\n🚀 Launching Ansible Playbook...\n")
|
||||||
# Capture output so the real Ansible error is visible before exit
|
|
||||||
result = subprocess.run(cmd)
|
result = subprocess.run(cmd)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -130,14 +146,17 @@ def run_ansible_playbook(
|
|||||||
sys.exit(result.returncode)
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
end_time = datetime.datetime.now()
|
end_time = datetime.datetime.now()
|
||||||
|
|
||||||
print(f"\n✅ Script ended at: {end_time.isoformat()}\n")
|
print(f"\n✅ Script ended at: {end_time.isoformat()}\n")
|
||||||
|
print(f"⏱️ Total execution time: {end_time - start_time}\n")
|
||||||
|
|
||||||
duration = end_time - start_time
|
|
||||||
print(f"⏱️ Total execution time: {duration}\n")
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# Application ID validation
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
def validate_application_ids(inventory: str, app_ids: List[str]) -> None:
|
def validate_application_ids(inventory: str, app_ids: List[str]) -> None:
|
||||||
"""Use ValidDeployId helper to ensure all requested IDs are valid."""
|
"""Validate requested application IDs using ValidDeployId."""
|
||||||
if not app_ids:
|
if not app_ids:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -145,25 +164,30 @@ def validate_application_ids(inventory: str, app_ids: List[str]) -> None:
|
|||||||
|
|
||||||
validator = ValidDeployId()
|
validator = ValidDeployId()
|
||||||
invalid = validator.validate(inventory, app_ids)
|
invalid = validator.validate(inventory, app_ids)
|
||||||
|
|
||||||
if invalid:
|
if invalid:
|
||||||
print("\n[ERROR] Some application_ids are invalid for this inventory:\n")
|
print("\n[ERROR] Some application_ids are invalid for this inventory:\n")
|
||||||
for app_id, status in invalid.items():
|
for app_id, status in invalid.items():
|
||||||
reasons: List[str] = []
|
reasons = []
|
||||||
if not status.get("allowed", True):
|
if not status.get("allowed", True):
|
||||||
reasons.append("not allowed by configuration")
|
reasons.append("not allowed by configuration")
|
||||||
if not status.get("in_inventory", True):
|
if not status.get("in_inventory", True):
|
||||||
reasons.append("not present in inventory")
|
reasons.append("not present in inventory")
|
||||||
print(f" - {app_id}: " + ", ".join(reasons))
|
print(f" - {app_id}: {', '.join(reasons)}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# MODE_* parsing logic
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
MODE_LINE_RE = re.compile(
|
MODE_LINE_RE = re.compile(
|
||||||
r"""^\s*(?P<key>[A-Z0-9_]+)\s*:\s*(?P<value>.+?)\s*(?:#\s*(?P<cmt>.*))?\s*$"""
|
r"""^\s*(?P<key>[A-Z0-9_]+)\s*:\s*(?P<value>.+?)\s*(?:#\s*(?P<cmt>.*))?\s*$"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool_literal(text: str) -> Optional[bool]:
|
def _parse_bool_literal(text: str) -> Optional[bool]:
|
||||||
"""Parse a simple true/false/yes/no/on/off into bool or None."""
|
"""Convert simple true/false/yes/no/on/off into boolean."""
|
||||||
t = text.strip().lower()
|
t = text.strip().lower()
|
||||||
if t in ("true", "yes", "on"):
|
if t in ("true", "yes", "on"):
|
||||||
return True
|
return True
|
||||||
@@ -173,12 +197,9 @@ def _parse_bool_literal(text: str) -> Optional[bool]:
|
|||||||
|
|
||||||
|
|
||||||
def load_modes_from_yaml(modes_yaml_path: str) -> List[Dict[str, Any]]:
|
def load_modes_from_yaml(modes_yaml_path: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""Load MODE_* definitions from YAML-like key/value file."""
|
||||||
Load MODE_* metadata from a simple key: value file.
|
|
||||||
|
|
||||||
Each non-comment, non-empty line is parsed via MODE_LINE_RE.
|
|
||||||
"""
|
|
||||||
modes: List[Dict[str, Any]] = []
|
modes: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
if not os.path.exists(modes_yaml_path):
|
if not os.path.exists(modes_yaml_path):
|
||||||
raise FileNotFoundError(f"Modes file not found: {modes_yaml_path}")
|
raise FileNotFoundError(f"Modes file not found: {modes_yaml_path}")
|
||||||
|
|
||||||
@@ -187,9 +208,11 @@ def load_modes_from_yaml(modes_yaml_path: str) -> List[Dict[str, Any]]:
|
|||||||
line = line.rstrip()
|
line = line.rstrip()
|
||||||
if not line or line.lstrip().startswith("#"):
|
if not line or line.lstrip().startswith("#"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m = MODE_LINE_RE.match(line)
|
m = MODE_LINE_RE.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
key = m.group("key")
|
key = m.group("key")
|
||||||
val = m.group("value").strip()
|
val = m.group("value").strip()
|
||||||
cmt = (m.group("cmt") or "").strip()
|
cmt = (m.group("cmt") or "").strip()
|
||||||
@@ -198,6 +221,7 @@ def load_modes_from_yaml(modes_yaml_path: str) -> List[Dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
default_bool = _parse_bool_literal(val)
|
default_bool = _parse_bool_literal(val)
|
||||||
|
|
||||||
modes.append(
|
modes.append(
|
||||||
{
|
{
|
||||||
"name": key,
|
"name": key,
|
||||||
@@ -205,20 +229,23 @@ def load_modes_from_yaml(modes_yaml_path: str) -> List[Dict[str, Any]]:
|
|||||||
"help": cmt or f"Toggle {key}",
|
"help": cmt or f"Toggle {key}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return modes
|
return modes
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# Dynamic argparse mode injection
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
def add_dynamic_mode_args(
|
def add_dynamic_mode_args(
|
||||||
parser: argparse.ArgumentParser, modes_meta: List[Dict[str, Any]]
|
parser: argparse.ArgumentParser, modes_meta: List[Dict[str, Any]]
|
||||||
) -> Dict[str, Dict[str, Any]]:
|
) -> Dict[str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Add dynamic CLI flags based on MODE_* metadata.
|
Add command-line arguments dynamically based on MODE_* metadata.
|
||||||
|
|
||||||
- MODE_FOO: true -> --skip-foo (default enabled, flag disables it)
|
|
||||||
- MODE_BAR: false -> --bar (default disabled, flag enables it)
|
|
||||||
- MODE_BAZ: null -> --baz {true,false} (explicit)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
spec: Dict[str, Dict[str, Any]] = {}
|
spec: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
for m in modes_meta:
|
for m in modes_meta:
|
||||||
name = m["name"]
|
name = m["name"]
|
||||||
default = m["default"]
|
default = m["default"]
|
||||||
@@ -226,25 +253,24 @@ def add_dynamic_mode_args(
|
|||||||
short = name.replace("MODE_", "").lower()
|
short = name.replace("MODE_", "").lower()
|
||||||
|
|
||||||
if default is True:
|
if default is True:
|
||||||
|
# MODE_FOO: true → --skip-foo disables it
|
||||||
opt = f"--skip-{short}"
|
opt = f"--skip-{short}"
|
||||||
dest = f"skip_{short}"
|
dest = f"skip_{short}"
|
||||||
help_txt = desc or f"Skip/disable {short} (default: enabled)"
|
parser.add_argument(opt, action="store_true", dest=dest, help=desc)
|
||||||
parser.add_argument(opt, action="store_true", help=help_txt, dest=dest)
|
|
||||||
spec[name] = {"dest": dest, "default": True, "kind": "bool_true"}
|
spec[name] = {"dest": dest, "default": True, "kind": "bool_true"}
|
||||||
|
|
||||||
elif default is False:
|
elif default is False:
|
||||||
|
# MODE_BAR: false → --bar enables it
|
||||||
opt = f"--{short}"
|
opt = f"--{short}"
|
||||||
dest = short
|
dest = short
|
||||||
help_txt = desc or f"Enable {short} (default: disabled)"
|
parser.add_argument(opt, action="store_true", dest=dest, help=desc)
|
||||||
parser.add_argument(opt, action="store_true", help=help_txt, dest=dest)
|
|
||||||
spec[name] = {"dest": dest, "default": False, "kind": "bool_false"}
|
spec[name] = {"dest": dest, "default": False, "kind": "bool_false"}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
# Explicit: MODE_XYZ: null → --xyz true|false
|
||||||
opt = f"--{short}"
|
opt = f"--{short}"
|
||||||
dest = short
|
dest = short
|
||||||
help_txt = (
|
parser.add_argument(opt, choices=["true", "false"], dest=dest, help=desc)
|
||||||
desc
|
|
||||||
or f"Set {short} explicitly (true/false). If omitted, keep inventory default."
|
|
||||||
)
|
|
||||||
parser.add_argument(opt, choices=["true", "false"], help=help_txt, dest=dest)
|
|
||||||
spec[name] = {"dest": dest, "default": None, "kind": "explicit"}
|
spec[name] = {"dest": dest, "default": None, "kind": "explicit"}
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
@@ -253,116 +279,76 @@ def add_dynamic_mode_args(
|
|||||||
def build_modes_from_args(
|
def build_modes_from_args(
|
||||||
spec: Dict[str, Dict[str, Any]], args_namespace: argparse.Namespace
|
spec: Dict[str, Dict[str, Any]], args_namespace: argparse.Namespace
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""Resolve CLI arguments into a MODE_* dictionary."""
|
||||||
Build a MODE_* dict from parsed CLI args and the dynamic spec.
|
|
||||||
"""
|
|
||||||
modes: Dict[str, Any] = {}
|
modes: Dict[str, Any] = {}
|
||||||
|
|
||||||
for mode_name, info in spec.items():
|
for mode_name, info in spec.items():
|
||||||
dest = info["dest"]
|
dest = info["dest"]
|
||||||
kind = info["kind"]
|
kind = info["kind"]
|
||||||
val = getattr(args_namespace, dest, None)
|
value = getattr(args_namespace, dest, None)
|
||||||
|
|
||||||
if kind == "bool_true":
|
if kind == "bool_true":
|
||||||
# default True, flag means "skip" → False
|
modes[mode_name] = False if value else True
|
||||||
modes[mode_name] = False if val else True
|
|
||||||
elif kind == "bool_false":
|
elif kind == "bool_false":
|
||||||
# default False, flag enables → True
|
modes[mode_name] = True if value else False
|
||||||
modes[mode_name] = True if val else False
|
|
||||||
else: # explicit
|
else: # explicit
|
||||||
if val is not None:
|
if value is not None:
|
||||||
modes[mode_name] = True if val == "true" else False
|
modes[mode_name] = (value == "true")
|
||||||
|
|
||||||
return modes
|
return modes
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
# Main entrypoint
|
||||||
|
# --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Deploy the Infinito.Nexus stack via ansible-playbook."
|
description="Deploy the Infinito.Nexus stack using ansible-playbook."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Standard arguments
|
||||||
|
parser.add_argument("inventory", help="Path to the inventory file.")
|
||||||
|
parser.add_argument("-l", "--limit", help="Limit execution to certain hosts or groups.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"inventory",
|
"-T", "--host-type", choices=["server", "desktop"], default="server",
|
||||||
help="Path to the inventory file (INI or YAML) containing hosts and variables.",
|
help="Specify target type: server or desktop."
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l",
|
"-p", "--password-file",
|
||||||
"--limit",
|
help="Vault password file for encrypted variables."
|
||||||
help="Restrict execution to a specific host or host group from the inventory.",
|
)
|
||||||
|
parser.add_argument("-B", "--skip-build", action="store_true", help="Skip build phase.")
|
||||||
|
parser.add_argument("-t", "--skip-tests", action="store_true", help="Skip test phase.")
|
||||||
|
parser.add_argument(
|
||||||
|
"-i", "--id", nargs="+", default=[], dest="id",
|
||||||
|
help="List of application_ids for partial deployment."
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-T",
|
"-v", "--verbose", action="count", default=0,
|
||||||
"--host-type",
|
help="Increase verbosity (e.g. -vvv)."
|
||||||
choices=["server", "desktop"],
|
|
||||||
default="server",
|
|
||||||
help=(
|
|
||||||
"Specify whether the target is a server or a personal computer. "
|
|
||||||
"Affects role selection and variables."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-p",
|
|
||||||
"--password-file",
|
|
||||||
help=(
|
|
||||||
"Path to the file containing the Vault password. "
|
|
||||||
"If not provided, ansible-vault will prompt interactively."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-B",
|
|
||||||
"--skip-build",
|
|
||||||
action="store_true",
|
|
||||||
help="Skip running 'make messy-build' before deployment.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-t",
|
|
||||||
"--skip-tests",
|
|
||||||
action="store_true",
|
|
||||||
help="Skip running 'make messy-test' before deployment.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-i",
|
|
||||||
"--id",
|
|
||||||
nargs="+",
|
|
||||||
default=[],
|
|
||||||
dest="id",
|
|
||||||
help=(
|
|
||||||
"List of application_id's for partial deploy. "
|
|
||||||
"If not set, all application IDs defined in the inventory will be executed."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-v",
|
|
||||||
"--verbose",
|
|
||||||
action="count",
|
|
||||||
default=0,
|
|
||||||
help=(
|
|
||||||
"Increase verbosity level. Multiple -v flags increase detail "
|
|
||||||
"(e.g., -vvv for maximum log output)."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--logs",
|
|
||||||
action="store_true",
|
|
||||||
help="Keep the CLI logs during cleanup command.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--diff",
|
|
||||||
action="store_true",
|
|
||||||
help="Pass --diff to ansible-playbook to show configuration changes.",
|
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--logs", action="store_true", help="Keep logs during cleanup.")
|
||||||
|
parser.add_argument("--diff", action="store_true", help="Enable Ansible diff mode.")
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
# Dynamic MODE_* parsing
|
||||||
repo_root = os.path.dirname(script_dir)
|
modes_yaml_path = os.path.join(REPO_ROOT, "group_vars", "all", "01_modes.yml")
|
||||||
modes_yaml_path = os.path.join(repo_root, "group_vars", "all", "01_modes.yml")
|
|
||||||
modes_meta = load_modes_from_yaml(modes_yaml_path)
|
modes_meta = load_modes_from_yaml(modes_yaml_path)
|
||||||
modes_spec = add_dynamic_mode_args(parser, modes_meta)
|
modes_spec = add_dynamic_mode_args(parser, modes_meta)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validate application IDs
|
||||||
validate_application_ids(args.inventory, args.id)
|
validate_application_ids(args.inventory, args.id)
|
||||||
|
|
||||||
|
# Build final mode map
|
||||||
modes = build_modes_from_args(modes_spec, args)
|
modes = build_modes_from_args(modes_spec, args)
|
||||||
modes["MODE_LOGS"] = args.logs
|
modes["MODE_LOGS"] = args.logs
|
||||||
modes["host_type"] = args.host_type
|
modes["host_type"] = args.host_type
|
||||||
|
|
||||||
|
# Run playbook
|
||||||
run_ansible_playbook(
|
run_ansible_playbook(
|
||||||
inventory=args.inventory,
|
inventory=args.inventory,
|
||||||
modes=modes,
|
modes=modes,
|
||||||
0
tests/unit/cli/deploy/__init__.py
Normal file
0
tests/unit/cli/deploy/__init__.py
Normal file
@@ -1,15 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import cli.deploy as deploy
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from cli.deploy import dedicated as deploy
|
||||||
|
|
||||||
|
|
||||||
class TestParseBoolLiteral(unittest.TestCase):
|
class TestParseBoolLiteral(unittest.TestCase):
|
||||||
def test_true_values(self):
|
def test_true_values(self):
|
||||||
@@ -32,13 +29,13 @@ class TestParseBoolLiteral(unittest.TestCase):
|
|||||||
|
|
||||||
class TestLoadModesFromYaml(unittest.TestCase):
|
class TestLoadModesFromYaml(unittest.TestCase):
|
||||||
def test_load_modes_basic(self):
|
def test_load_modes_basic(self):
|
||||||
# Create a temporary "01_modes.yml"-like file
|
|
||||||
content = """\
|
content = """\
|
||||||
MODE_CLEANUP: true # cleanup before deploy
|
MODE_CLEANUP: true # cleanup before deploy
|
||||||
MODE_DEBUG: false # enable debug
|
MODE_DEBUG: false # debug output
|
||||||
MODE_ASSERT: null # explicitly set via CLI
|
MODE_ASSERT: null # inventory assertion
|
||||||
INVALID_KEY: true # ignored because no MODE_ prefix
|
OTHER_KEY: true # should be ignored (no MODE_ prefix)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile("w+", delete=False, encoding="utf-8") as f:
|
with tempfile.NamedTemporaryFile("w+", delete=False, encoding="utf-8") as f:
|
||||||
path = f.name
|
path = f.name
|
||||||
f.write(content)
|
f.write(content)
|
||||||
@@ -49,7 +46,6 @@ INVALID_KEY: true # ignored because no MODE_ prefix
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
# We expect 3 MODE_* entries, INVALID_KEY is ignored
|
|
||||||
self.assertEqual(len(modes), 3)
|
self.assertEqual(len(modes), 3)
|
||||||
|
|
||||||
by_name = {m["name"]: m for m in modes}
|
by_name = {m["name"]: m for m in modes}
|
||||||
@@ -63,10 +59,29 @@ INVALID_KEY: true # ignored because no MODE_ prefix
|
|||||||
self.assertIsNone(by_name["MODE_ASSERT"]["default"])
|
self.assertIsNone(by_name["MODE_ASSERT"]["default"])
|
||||||
self.assertEqual(by_name["MODE_CLEANUP"]["help"], "cleanup before deploy")
|
self.assertEqual(by_name["MODE_CLEANUP"]["help"], "cleanup before deploy")
|
||||||
|
|
||||||
|
def test_load_modes_ignores_non_mode_keys(self):
|
||||||
|
content = """\
|
||||||
|
FOO: true
|
||||||
|
BAR: false
|
||||||
|
MODE_FOO: true
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile("w+", delete=False, encoding="utf-8") as f:
|
||||||
|
path = f.name
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
modes = deploy.load_modes_from_yaml(path)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
self.assertEqual(len(modes), 1)
|
||||||
|
self.assertEqual(modes[0]["name"], "MODE_FOO")
|
||||||
|
|
||||||
|
|
||||||
class TestDynamicModes(unittest.TestCase):
|
class TestDynamicModes(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Simple meta as if parsed from 01_modes.yml
|
|
||||||
self.modes_meta = [
|
self.modes_meta = [
|
||||||
{"name": "MODE_CLEANUP", "default": True, "help": "Cleanup before run"},
|
{"name": "MODE_CLEANUP", "default": True, "help": "Cleanup before run"},
|
||||||
{"name": "MODE_DEBUG", "default": False, "help": "Debug output"},
|
{"name": "MODE_DEBUG", "default": False, "help": "Debug output"},
|
||||||
@@ -74,59 +89,62 @@ class TestDynamicModes(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_add_dynamic_mode_args_and_build_modes_defaults(self):
|
def test_add_dynamic_mode_args_and_build_modes_defaults(self):
|
||||||
parser = unittest.mock.MagicMock()
|
|
||||||
# Use a real ArgumentParser for build_modes_from_args
|
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
real_parser = ArgumentParser()
|
real_parser = ArgumentParser()
|
||||||
spec = deploy.add_dynamic_mode_args(real_parser, self.modes_meta)
|
spec = deploy.add_dynamic_mode_args(real_parser, self.modes_meta)
|
||||||
|
|
||||||
# We expect three entries
|
|
||||||
self.assertIn("MODE_CLEANUP", spec)
|
self.assertIn("MODE_CLEANUP", spec)
|
||||||
self.assertIn("MODE_DEBUG", spec)
|
self.assertIn("MODE_DEBUG", spec)
|
||||||
self.assertIn("MODE_ASSERT", spec)
|
self.assertIn("MODE_ASSERT", spec)
|
||||||
|
|
||||||
# No flags given: use defaults (True/False/None)
|
# No flags passed → defaults apply
|
||||||
args = real_parser.parse_args([])
|
args = real_parser.parse_args([])
|
||||||
modes = deploy.build_modes_from_args(spec, args)
|
modes = deploy.build_modes_from_args(spec, args)
|
||||||
|
|
||||||
self.assertTrue(modes["MODE_CLEANUP"]) # default True
|
self.assertTrue(modes["MODE_CLEANUP"]) # default True
|
||||||
self.assertFalse(modes["MODE_DEBUG"]) # default False
|
self.assertFalse(modes["MODE_DEBUG"]) # default False
|
||||||
self.assertNotIn("MODE_ASSERT", modes) # default None → not present
|
self.assertNotIn("MODE_ASSERT", modes) # explicit, not set
|
||||||
|
|
||||||
def test_add_dynamic_mode_args_and_build_modes_flags(self):
|
def test_add_dynamic_mode_args_and_build_modes_flags_true(self):
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
parser = ArgumentParser()
|
parser = ArgumentParser()
|
||||||
spec = deploy.add_dynamic_mode_args(parser, self.modes_meta)
|
spec = deploy.add_dynamic_mode_args(parser, self.modes_meta)
|
||||||
|
|
||||||
# CLI: --skip-cleanup → MODE_CLEANUP=False
|
# MODE_CLEANUP: true → --skip-cleanup sets it to False
|
||||||
# --debug → MODE_DEBUG=True
|
# MODE_DEBUG: false → --debug sets it to True
|
||||||
# --assert true → MODE_ASSERT=True
|
# MODE_ASSERT: None → --assert true sets it to True
|
||||||
args = parser.parse_args(
|
args = parser.parse_args(["--skip-cleanup", "--debug", "--assert", "true"])
|
||||||
["--skip-cleanup", "--debug", "--assert", "true"]
|
|
||||||
)
|
|
||||||
modes = deploy.build_modes_from_args(spec, args)
|
modes = deploy.build_modes_from_args(spec, args)
|
||||||
|
|
||||||
self.assertFalse(modes["MODE_CLEANUP"])
|
self.assertFalse(modes["MODE_CLEANUP"])
|
||||||
self.assertTrue(modes["MODE_DEBUG"])
|
self.assertTrue(modes["MODE_DEBUG"])
|
||||||
self.assertTrue(modes["MODE_ASSERT"])
|
self.assertTrue(modes["MODE_ASSERT"])
|
||||||
|
|
||||||
|
def test_add_dynamic_mode_args_and_build_modes_flags_false_explicit(self):
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
|
parser = ArgumentParser()
|
||||||
|
spec = deploy.add_dynamic_mode_args(parser, self.modes_meta)
|
||||||
|
|
||||||
|
# explicit false for MODE_ASSERT
|
||||||
|
args = parser.parse_args(["--assert", "false"])
|
||||||
|
modes = deploy.build_modes_from_args(spec, args)
|
||||||
|
|
||||||
|
self.assertTrue(modes["MODE_CLEANUP"]) # still default True
|
||||||
|
self.assertFalse(modes["MODE_DEBUG"]) # still default False
|
||||||
|
self.assertIn("MODE_ASSERT", modes)
|
||||||
|
self.assertFalse(modes["MODE_ASSERT"])
|
||||||
|
|
||||||
|
|
||||||
class TestValidateApplicationIds(unittest.TestCase):
|
class TestValidateApplicationIds(unittest.TestCase):
|
||||||
def test_no_ids_does_nothing(self):
|
def test_no_ids_does_nothing(self):
|
||||||
"""
|
# Should not raise
|
||||||
When no application_ids are passed, the function should just return
|
|
||||||
without trying to validate anything.
|
|
||||||
"""
|
|
||||||
deploy.validate_application_ids("inventories/github-ci/servers.yml", [])
|
deploy.validate_application_ids("inventories/github-ci/servers.yml", [])
|
||||||
|
|
||||||
@mock.patch("module_utils.valid_deploy_id.ValidDeployId")
|
@mock.patch("module_utils.valid_deploy_id.ValidDeployId")
|
||||||
def test_invalid_ids_raise_system_exit(self, mock_vdi_cls):
|
def test_invalid_ids_raise_system_exit(self, mock_vdi_cls):
|
||||||
"""
|
|
||||||
When ValidDeployId reports invalid IDs, validate_application_ids should
|
|
||||||
print an error and exit with code 1.
|
|
||||||
"""
|
|
||||||
instance = mock_vdi_cls.return_value
|
instance = mock_vdi_cls.return_value
|
||||||
instance.validate.return_value = {
|
instance.validate.return_value = {
|
||||||
"web-app-foo": {"allowed": False, "in_inventory": True},
|
"web-app-foo": {"allowed": False, "in_inventory": True},
|
||||||
@@ -153,20 +171,18 @@ class TestRunAnsiblePlaybook(unittest.TestCase):
|
|||||||
ansible_rc: int = 0,
|
ansible_rc: int = 0,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
side_effect for subprocess.run that:
|
Side effect for subprocess.run that:
|
||||||
|
- records all commands to calls_store
|
||||||
- Records every command into calls_store
|
- returns a CompletedProcess with ansible_rc for ansible-playbook
|
||||||
- Returns 'ansible_rc' for ansible-playbook
|
- returns success (rc=0) for all other commands
|
||||||
- Returns rc=0 for all other commands (make, validation, etc.)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _side_effect(cmd, *args, **kwargs):
|
def _side_effect(cmd, *args, **kwargs):
|
||||||
# Normalize into a list for easier assertions
|
|
||||||
if isinstance(cmd, list):
|
if isinstance(cmd, list):
|
||||||
calls_store.append(cmd)
|
calls_store.append(cmd)
|
||||||
else:
|
else:
|
||||||
calls_store.append([cmd])
|
calls_store.append([cmd])
|
||||||
|
|
||||||
# Special handling for ansible-playbook
|
|
||||||
if isinstance(cmd, list) and cmd and cmd[0] == "ansible-playbook":
|
if isinstance(cmd, list) and cmd and cmd[0] == "ansible-playbook":
|
||||||
return subprocess.CompletedProcess(
|
return subprocess.CompletedProcess(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -175,28 +191,19 @@ class TestRunAnsiblePlaybook(unittest.TestCase):
|
|||||||
stderr="ANSIBLE_STDERR\n",
|
stderr="ANSIBLE_STDERR\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Everything else (make, python inventory) is treated as success
|
|
||||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||||
|
|
||||||
return _side_effect
|
return _side_effect
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
@mock.patch("subprocess.run")
|
||||||
def test_run_ansible_playbook_builds_correct_command(self, mock_run):
|
def test_run_ansible_playbook_builds_correct_command(self, mock_run):
|
||||||
"""
|
|
||||||
Happy-path test:
|
|
||||||
|
|
||||||
- MODE_CLEANUP=True → 'make clean' is executed
|
|
||||||
- build/test phases are executed
|
|
||||||
- ansible-playbook is called with expected arguments
|
|
||||||
- commands are fully mocked (no real execution)
|
|
||||||
"""
|
|
||||||
calls: List[List[str]] = []
|
calls: List[List[str]] = []
|
||||||
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=0)
|
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=0)
|
||||||
|
|
||||||
modes: Dict[str, Any] = {
|
modes: Dict[str, Any] = {
|
||||||
"MODE_CLEANUP": True,
|
"MODE_CLEANUP": True,
|
||||||
"MODE_ASSERT": True,
|
"MODE_ASSERT": True,
|
||||||
"MODE_DEBUG": True, # should enforce at least -vvv
|
"MODE_DEBUG": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
inventory_path = "inventories/github-ci/servers.yml"
|
inventory_path = "inventories/github-ci/servers.yml"
|
||||||
@@ -210,16 +217,14 @@ class TestRunAnsiblePlaybook(unittest.TestCase):
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
allowed_applications=allowed_apps,
|
allowed_applications=allowed_apps,
|
||||||
password_file=password_file,
|
password_file=password_file,
|
||||||
verbose=1, # explicitly set, then raised by MODE_DEBUG
|
verbose=1,
|
||||||
skip_build=False,
|
skip_build=False,
|
||||||
skip_tests=False,
|
skip_tests=False,
|
||||||
logs=False,
|
logs=False,
|
||||||
diff=True,
|
diff=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# We expect at least: make clean, make messy-build, make messy-test,
|
# Cleanup, build, tests
|
||||||
# plus the ansible-playbook invocation. Inventory validation is also
|
|
||||||
# done via python ... inventory.py but we do not assert the full path.
|
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any(call == ["make", "clean"] for call in calls),
|
any(call == ["make", "clean"] for call in calls),
|
||||||
"Expected 'make clean' when MODE_CLEANUP is true",
|
"Expected 'make clean' when MODE_CLEANUP is true",
|
||||||
@@ -241,12 +246,12 @@ class TestRunAnsiblePlaybook(unittest.TestCase):
|
|||||||
"Expected inventory validation call (... inventory.py ...)",
|
"Expected inventory validation call (... inventory.py ...)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Last command should be ansible-playbook
|
# The last call should be ansible-playbook
|
||||||
self.assertGreaterEqual(len(calls), 1)
|
self.assertGreaterEqual(len(calls), 1)
|
||||||
last_cmd = calls[-1]
|
last_cmd = calls[-1]
|
||||||
self.assertEqual(last_cmd[0], "ansible-playbook")
|
self.assertEqual(last_cmd[0], "ansible-playbook")
|
||||||
|
|
||||||
# Check inventory and playbook args
|
# Inventory and playbook ordering: -i <inventory> <playbook>
|
||||||
self.assertIn("-i", last_cmd)
|
self.assertIn("-i", last_cmd)
|
||||||
self.assertIn(inventory_path, last_cmd)
|
self.assertIn(inventory_path, last_cmd)
|
||||||
|
|
||||||
@@ -254,22 +259,22 @@ class TestRunAnsiblePlaybook(unittest.TestCase):
|
|||||||
self.assertGreater(len(last_cmd), idx_inv + 1)
|
self.assertGreater(len(last_cmd), idx_inv + 1)
|
||||||
playbook_arg = last_cmd[idx_inv + 1]
|
playbook_arg = last_cmd[idx_inv + 1]
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
playbook_arg.endswith(os.path.join("", "playbook.yml")),
|
playbook_arg.endswith("playbook.yml"),
|
||||||
f"playbook argument should end with 'playbook.yml', got: {playbook_arg}",
|
f"playbook argument should end with 'playbook.yml', got: {playbook_arg}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check --limit / -l
|
# Limit handling
|
||||||
self.assertIn("-l", last_cmd)
|
self.assertIn("-l", last_cmd)
|
||||||
self.assertIn(limit, last_cmd)
|
self.assertIn(limit, last_cmd)
|
||||||
|
|
||||||
# allowed_applications extra var
|
# Allowed applications extra var
|
||||||
last_cmd_str = " ".join(last_cmd)
|
last_cmd_str = " ".join(last_cmd)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"allowed_applications=web-app-foo,web-app-bar",
|
"allowed_applications=web-app-foo,web-app-bar",
|
||||||
last_cmd_str,
|
last_cmd_str,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Modes passed as -e
|
# MODE_* variables
|
||||||
self.assertIn("MODE_CLEANUP=true", last_cmd_str)
|
self.assertIn("MODE_CLEANUP=true", last_cmd_str)
|
||||||
self.assertIn("MODE_ASSERT=true", last_cmd_str)
|
self.assertIn("MODE_ASSERT=true", last_cmd_str)
|
||||||
self.assertIn("MODE_DEBUG=true", last_cmd_str)
|
self.assertIn("MODE_DEBUG=true", last_cmd_str)
|
||||||
@@ -278,27 +283,22 @@ class TestRunAnsiblePlaybook(unittest.TestCase):
|
|||||||
self.assertIn("--vault-password-file", last_cmd)
|
self.assertIn("--vault-password-file", last_cmd)
|
||||||
self.assertIn(password_file, last_cmd)
|
self.assertIn(password_file, last_cmd)
|
||||||
|
|
||||||
# --diff should be present
|
# Diff flag
|
||||||
self.assertIn("--diff", last_cmd)
|
self.assertIn("--diff", last_cmd)
|
||||||
|
|
||||||
# Verbosity: MODE_DEBUG enforces at least -vvv regardless of initial -v
|
# Verbosity should be at least -vvv when MODE_DEBUG=True
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any(arg.startswith("-vvv") for arg in last_cmd),
|
any(arg.startswith("-vvv") for arg in last_cmd),
|
||||||
"Expected at least -vvv because MODE_DEBUG=True",
|
"Expected at least -vvv because MODE_DEBUG=True",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify that for ansible-playbook, we asked for text+capture_output
|
# Ensure we did not accidentally set text/capture_output in the final run
|
||||||
last_call = mock_run.call_args_list[-1]
|
last_call = mock_run.call_args_list[-1]
|
||||||
# In streaming mode we no longer pass text/capture_output to subprocess.run
|
|
||||||
self.assertNotIn("text", last_call.kwargs)
|
self.assertNotIn("text", last_call.kwargs)
|
||||||
self.assertNotIn("capture_output", last_call.kwargs)
|
self.assertNotIn("capture_output", last_call.kwargs)
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
@mock.patch("subprocess.run")
|
||||||
def test_run_ansible_playbook_failure_exits_with_code(self, mock_run):
|
def test_run_ansible_playbook_failure_exits_with_code_and_skips_phases(self, mock_run):
|
||||||
"""
|
|
||||||
If ansible-playbook returns non-zero, run_ansible_playbook should exit
|
|
||||||
with the same code. All external commands are mocked.
|
|
||||||
"""
|
|
||||||
calls: List[List[str]] = []
|
calls: List[List[str]] = []
|
||||||
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=4)
|
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=4)
|
||||||
|
|
||||||
@@ -327,6 +327,46 @@ class TestRunAnsiblePlaybook(unittest.TestCase):
|
|||||||
any(call and call[0] == "ansible-playbook" for call in calls),
|
any(call and call[0] == "ansible-playbook" for call in calls),
|
||||||
"ansible-playbook should have been invoked once in the error path",
|
"ansible-playbook should have been invoked once in the error path",
|
||||||
)
|
)
|
||||||
|
# No cleanup, no build, no tests, no inventory validation
|
||||||
|
self.assertFalse(any(call == ["make", "clean"] for call in calls))
|
||||||
|
self.assertFalse(any(call == ["make", "messy-build"] for call in calls))
|
||||||
|
self.assertFalse(any(call == ["make", "messy-test"] for call in calls))
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
isinstance(call, list)
|
||||||
|
and any("inventory.py" in part for part in call)
|
||||||
|
for call in calls
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("subprocess.run")
|
||||||
|
def test_run_ansible_playbook_cleanup_with_logs_uses_clean_keep_logs(self, mock_run):
|
||||||
|
calls: List[List[str]] = []
|
||||||
|
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=0)
|
||||||
|
|
||||||
|
modes: Dict[str, Any] = {
|
||||||
|
"MODE_CLEANUP": True,
|
||||||
|
"MODE_ASSERT": False,
|
||||||
|
"MODE_DEBUG": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy.run_ansible_playbook(
|
||||||
|
inventory="inventories/github-ci/servers.yml",
|
||||||
|
modes=modes,
|
||||||
|
limit=None,
|
||||||
|
allowed_applications=None,
|
||||||
|
password_file=None,
|
||||||
|
verbose=0,
|
||||||
|
skip_build=True,
|
||||||
|
skip_tests=True,
|
||||||
|
logs=True,
|
||||||
|
diff=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any(call == ["make", "clean-keep-logs"] for call in calls),
|
||||||
|
"Expected 'make clean-keep-logs' when MODE_CLEANUP=true and logs=True",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Reference in New Issue
Block a user