mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-10-31 10:19:09 +00:00 
			
		
		
		
	deploy(cli): auto-generate MODE_* flags from 01_modes.yml; remove legacy skip flags/params; drive cleanup via MODE_CLEANUP; validation via MODE_ASSERT; tests via MODE_TEST; drop MODE_BACKUP from 01_modes.yml. Ref: https://chatgpt.com/share/68c3725f-43a0-800f-9bb0-eb7cbf77ac24
This commit is contained in:
		
							
								
								
									
										254
									
								
								cli/deploy.py
									
									
									
									
									
								
							
							
						
						
									
										254
									
								
								cli/deploy.py
									
									
									
									
									
								
							| @@ -5,6 +5,9 @@ import subprocess | ||||
| import os | ||||
| import datetime | ||||
| import sys | ||||
| import re | ||||
| from typing import Optional, Dict, Any, List | ||||
|  | ||||
|  | ||||
| def run_ansible_playbook( | ||||
|     inventory, | ||||
| @@ -13,21 +16,19 @@ def run_ansible_playbook( | ||||
|     allowed_applications=None, | ||||
|     password_file=None, | ||||
|     verbose=0, | ||||
|     skip_tests=False, | ||||
|     skip_validation=False, | ||||
|     skip_build=False, | ||||
|     cleanup=False, | ||||
|     logs=False | ||||
| ): | ||||
|     start_time = datetime.datetime.now() | ||||
|     print(f"\n▶️ Script started at: {start_time.isoformat()}\n") | ||||
|  | ||||
|     if cleanup: | ||||
|     # Cleanup is now handled via MODE_CLEANUP | ||||
|     if modes.get("MODE_CLEANUP", False): | ||||
|         cleanup_command = ["make", "clean-keep-logs"] if logs else ["make", "clean"] | ||||
|         print("\n🧹 Cleaning up project (" + " ".join(cleanup_command) +")...\n") | ||||
|         print("\n🧹 Cleaning up project (" + " ".join(cleanup_command) + ")...\n") | ||||
|         subprocess.run(cleanup_command, check=True) | ||||
|     else: | ||||
|         print("\n⚠️ Skipping build as requested.\n") | ||||
|         print("\n⚠️ Skipping cleanup as requested.\n") | ||||
|  | ||||
|     if not skip_build: | ||||
|         print("\n🛠️  Building project (make messy-build)...\n") | ||||
| @@ -38,27 +39,26 @@ def run_ansible_playbook( | ||||
|     script_dir = os.path.dirname(os.path.realpath(__file__)) | ||||
|     playbook = os.path.join(os.path.dirname(script_dir), "playbook.yml") | ||||
|  | ||||
|     # Inventory validation step | ||||
|     if not skip_validation: | ||||
|     # Inventory validation is controlled via MODE_ASSERT | ||||
|     if modes.get("MODE_ASSERT", None) is False: | ||||
|         print("\n⚠️ Skipping inventory validation as requested.\n") | ||||
|     elif "MODE_ASSERT" not in modes or modes["MODE_ASSERT"] is True: | ||||
|         print("\n🔍 Validating inventory before deployment...\n") | ||||
|         try: | ||||
|             subprocess.run( | ||||
|                 [sys.executable, | ||||
|                  os.path.join(script_dir, "validate/inventory.py"), | ||||
|                  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: | ||||
|             print( | ||||
|                 "\n❌ Inventory validation failed. Deployment aborted.\n", | ||||
|                 file=sys.stderr | ||||
|             ) | ||||
|             print("\n❌ Inventory validation failed. Deployment aborted.\n", file=sys.stderr) | ||||
|             sys.exit(1) | ||||
|     else: | ||||
|         print("\n⚠️ Skipping inventory validation as requested.\n") | ||||
|  | ||||
|     if not skip_tests: | ||||
|     # Tests are controlled via MODE_TEST | ||||
|     if modes.get("MODE_TEST", False): | ||||
|         print("\n🧪 Running tests (make messy-test)...\n") | ||||
|         subprocess.run(["make", "messy-test"], check=True) | ||||
|  | ||||
| @@ -93,25 +93,136 @@ def run_ansible_playbook( | ||||
|     duration = end_time - start_time | ||||
|     print(f"⏱️ Total execution time: {duration}\n") | ||||
|  | ||||
|  | ||||
| def validate_application_ids(inventory, app_ids): | ||||
|     """ | ||||
|     Abort the script if any application IDs are invalid, with detailed reasons. | ||||
|     """ | ||||
|     from module_utils.valid_deploy_id import ValidDeployId | ||||
|  | ||||
|     validator = ValidDeployId() | ||||
|     invalid = validator.validate(inventory, app_ids) | ||||
|     if invalid: | ||||
|         print("\n❌ Detected invalid application_id(s):\n") | ||||
|         for app_id, status in invalid.items(): | ||||
|             reasons = [] | ||||
|             if not status['in_roles']: | ||||
|             if not status["in_roles"]: | ||||
|                 reasons.append("not defined in roles (infinito)") | ||||
|             if not status['in_inventory']: | ||||
|             if not status["in_inventory"]: | ||||
|                 reasons.append("not found in inventory file") | ||||
|             print(f"  - {app_id}: " + ", ".join(reasons)) | ||||
|         sys.exit(1) | ||||
|  | ||||
|  | ||||
| MODE_LINE_RE = re.compile( | ||||
|     r"""^\s*(?P<key>[A-Z0-9_]+)\s*:\s*(?P<value>.+?)\s*(?:#\s*(?P<cmt>.*))?\s*$""" | ||||
| ) | ||||
|  | ||||
|  | ||||
| def _parse_bool_literal(text: str) -> Optional[bool]: | ||||
|     t = text.strip().lower() | ||||
|     if t in ("true", "yes", "on"): | ||||
|         return True | ||||
|     if t in ("false", "no", "off"): | ||||
|         return False | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def load_modes_from_yaml(modes_yaml_path: str) -> List[Dict[str, Any]]: | ||||
|     """ | ||||
|     Parse group_vars/all/01_modes.yml line-by-line to recover: | ||||
|       - name (e.g., MODE_TEST) | ||||
|       - default (True/False/None if templated/unknown) | ||||
|       - help (from trailing # comment, if present) | ||||
|     """ | ||||
|     modes = [] | ||||
|     if not os.path.exists(modes_yaml_path): | ||||
|         raise FileNotFoundError(f"Modes file not found: {modes_yaml_path}") | ||||
|  | ||||
|     with open(modes_yaml_path, "r", encoding="utf-8") as fh: | ||||
|         for line in fh: | ||||
|             line = line.rstrip() | ||||
|             if not line or line.lstrip().startswith("#"): | ||||
|                 continue | ||||
|             m = MODE_LINE_RE.match(line) | ||||
|             if not m: | ||||
|                 continue | ||||
|             key = m.group("key") | ||||
|             val = m.group("value").strip() | ||||
|             cmt = (m.group("cmt") or "").strip() | ||||
|  | ||||
|             if not key.startswith("MODE_"): | ||||
|                 continue | ||||
|  | ||||
|             default_bool = _parse_bool_literal(val) | ||||
|             modes.append( | ||||
|                 { | ||||
|                     "name": key, | ||||
|                     "default": default_bool, | ||||
|                     "help": cmt or f"Toggle {key}", | ||||
|                 } | ||||
|             ) | ||||
|     return modes | ||||
|  | ||||
|  | ||||
| def add_dynamic_mode_args( | ||||
|     parser: argparse.ArgumentParser, modes_meta: List[Dict[str, Any]] | ||||
| ) -> Dict[str, Dict[str, Any]]: | ||||
|     """ | ||||
|     Add argparse options based on modes metadata. | ||||
|     Returns a dict mapping mode name -> { 'dest': <argparse_dest>, 'default': <bool/None>, 'kind': 'bool_true'|'bool_false'|'explicit' }. | ||||
|     """ | ||||
|     spec: Dict[str, Dict[str, Any]] = {} | ||||
|     for m in modes_meta: | ||||
|         name = m["name"] | ||||
|         default = m["default"] | ||||
|         desc = m["help"] | ||||
|         short = name.replace("MODE_", "").lower() | ||||
|  | ||||
|         if default is True: | ||||
|             opt = f"--skip-{short}" | ||||
|             dest = f"skip_{short}" | ||||
|             help_txt = desc or f"Skip/disable {short} (default: enabled)" | ||||
|             parser.add_argument(opt, action="store_true", help=help_txt, dest=dest) | ||||
|             spec[name] = {"dest": dest, "default": True, "kind": "bool_true"} | ||||
|         elif default is False: | ||||
|             opt = f"--{short}" | ||||
|             dest = short | ||||
|             help_txt = desc or f"Enable {short} (default: disabled)" | ||||
|             parser.add_argument(opt, action="store_true", help=help_txt, dest=dest) | ||||
|             spec[name] = {"dest": dest, "default": False, "kind": "bool_false"} | ||||
|         else: | ||||
|             opt = f"--{short}" | ||||
|             dest = short | ||||
|             help_txt = 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"} | ||||
|  | ||||
|     return spec | ||||
|  | ||||
|  | ||||
| def build_modes_from_args( | ||||
|     spec: Dict[str, Dict[str, Any]], args_namespace: argparse.Namespace | ||||
| ) -> Dict[str, Any]: | ||||
|     """ | ||||
|     Using the argparse results and the spec, compute the `modes` dict to pass to Ansible. | ||||
|     """ | ||||
|     modes: Dict[str, Any] = {} | ||||
|     for mode_name, info in spec.items(): | ||||
|         dest = info["dest"] | ||||
|         kind = info["kind"] | ||||
|         val = getattr(args_namespace, dest, None) | ||||
|  | ||||
|         if kind == "bool_true": | ||||
|             modes[mode_name] = False if val else True | ||||
|         elif kind == "bool_false": | ||||
|             modes[mode_name] = True if val else False | ||||
|         else: | ||||
|             if val is not None: | ||||
|                 modes[mode_name] = True if val == "true" else False | ||||
|     return modes | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     parser = argparse.ArgumentParser( | ||||
|         description="Run the central Ansible deployment script to manage infrastructure, updates, and tests." | ||||
| @@ -119,88 +230,68 @@ def main(): | ||||
|  | ||||
|     parser.add_argument( | ||||
|         "inventory", | ||||
|         help="Path to the inventory file (INI or YAML) containing hosts and variables." | ||||
|         help="Path to the inventory file (INI or YAML) containing hosts and variables.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-l", "--limit", | ||||
|         help="Restrict execution to a specific host or host group from the inventory." | ||||
|         "-l", | ||||
|         "--limit", | ||||
|         help="Restrict execution to a specific host or host group from the inventory.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-T", "--host-type", | ||||
|         "-T", | ||||
|         "--host-type", | ||||
|         choices=["server", "desktop"], | ||||
|         default="server", | ||||
|         help="Specify whether the target is a server or a personal computer. Affects role selection and variables." | ||||
|         help="Specify whether the target is a server or a personal computer. Affects role selection and variables.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-r", "--reset", action="store_true", | ||||
|         help="Reset all Infinito.Nexus files and configurations, and run the entire playbook (not just individual roles)." | ||||
|         "-p", | ||||
|         "--password-file", | ||||
|         help="Path to the file containing the Vault password. If not provided, prompts for the password interactively.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-t", "--test", action="store_true", | ||||
|         help="Run test routines instead of production tasks. Useful for local testing and CI pipelines." | ||||
|         "-B", | ||||
|         "--skip-build", | ||||
|         action="store_true", | ||||
|         help="Skip running 'make build' before deployment.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-u", "--update", action="store_true", | ||||
|         help="Enable the update procedure to bring software and roles up to date." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-b", "--backup", action="store_true", | ||||
|         help="Perform a full backup of critical data and configurations before the update process." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-c", "--cleanup", action="store_true", | ||||
|         help="Clean up unused files and outdated configurations after all tasks are complete. Also cleans up the repository before the deployment procedure." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-d", "--debug", action="store_true", | ||||
|         help="Enable detailed debug output for Ansible and this script." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-p", "--password-file", | ||||
|         help="Path to the file containing the Vault password. If not provided, prompts for the password interactively." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-s", "--skip-tests", action="store_true", | ||||
|         help="Skip running 'make test' even if tests are normally enabled." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-V", "--skip-validation", action="store_true", | ||||
|         help="Skip inventory validation before deployment." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-B", "--skip-build", action="store_true", | ||||
|         help="Skip running 'make build' before deployment." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-i", "--id",  | ||||
|         "-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." | ||||
|         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)." | ||||
|         "-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" | ||||
|         "--logs", | ||||
|         action="store_true", | ||||
|         help="Keep the CLI logs during cleanup command", | ||||
|     ) | ||||
|  | ||||
|     # ---- Dynamically add mode flags from group_vars/all/01_modes.yml ---- | ||||
|     script_dir = os.path.dirname(os.path.realpath(__file__)) | ||||
|     repo_root = os.path.dirname(script_dir) | ||||
|     modes_yaml_path = os.path.join(repo_root, "group_vars", "all", "01_modes.yml") | ||||
|     modes_meta = load_modes_from_yaml(modes_yaml_path) | ||||
|     modes_spec = add_dynamic_mode_args(parser, modes_meta) | ||||
|  | ||||
|     args = parser.parse_args() | ||||
|     validate_application_ids(args.inventory, args.id) | ||||
|  | ||||
|     modes = { | ||||
|         "MODE_RESET": args.reset, | ||||
|         "MODE_TEST": args.test, | ||||
|         "MODE_UPDATE": args.update, | ||||
|         "MODE_BACKUP": args.backup, | ||||
|         "MODE_CLEANUP": args.cleanup, | ||||
|         "MODE_LOGS": args.logs, | ||||
|         "MODE_DEBUG": args.debug, | ||||
|         "MODE_ASSERT": not args.skip_validation, | ||||
|         "host_type": args.host_type | ||||
|     } | ||||
|     # Build modes from dynamic args | ||||
|     modes = build_modes_from_args(modes_spec, args) | ||||
|  | ||||
|     # Additional non-dynamic flags | ||||
|     modes["MODE_LOGS"] = args.logs | ||||
|     modes["host_type"] = args.host_type | ||||
|  | ||||
|     run_ansible_playbook( | ||||
|         inventory=args.inventory, | ||||
| @@ -209,11 +300,8 @@ def main(): | ||||
|         allowed_applications=args.id, | ||||
|         password_file=args.password_file, | ||||
|         verbose=args.verbose, | ||||
|         skip_tests=args.skip_tests, | ||||
|         skip_validation=args.skip_validation, | ||||
|         skip_build=args.skip_build, | ||||
|         cleanup=args.cleanup, | ||||
|         logs=args.logs | ||||
|         logs=args.logs, | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,5 @@ MODE_TEST:    false                       # Executes test routines instead of pr | ||||
| MODE_UPDATE:  true                        # Executes updates | ||||
| MODE_DEBUG:   false                       # This enables debugging in ansible and in the apps, You SHOULD NOT enable this on production servers | ||||
| MODE_RESET:   false                       # Cleans up all Infinito.Nexus files. It's necessary to run to whole playbook and not particial roles when using this function. | ||||
| MODE_BACKUP:  "{{ MODE_UPDATE | bool }}"  # Activates the backup before the update procedure | ||||
| MODE_CLEANUP: "{{ MODE_DEBUG  | bool }}"  # Cleanup unused files and configurations | ||||
| MODE_ASSERT:  "{{ MODE_DEBUG  | bool }}"  # Executes validation tasks during the run. | ||||
|   | ||||
		Reference in New Issue
	
	Block a user