diff --git a/cortex/cli.py b/cortex/cli.py index 4842de59..2149c210 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -1,10 +1,11 @@ import argparse import logging import os +import subprocess import sys import time from datetime import datetime -from typing import Any +from typing import Any, Optional from cortex.api_key_detector import auto_detect_api_key, setup_api_key from cortex.ask import AskHandler @@ -23,7 +24,11 @@ from cortex.network_config import NetworkConfig from cortex.notification_manager import NotificationManager from cortex.stack_manager import StackManager -from cortex.validators import validate_api_key, validate_install_request +from cortex.templates import InstallationStep, Template, TemplateFormat, TemplateManager +from cortex.validators import ( + validate_api_key, + validate_install_request, +) # Suppress noisy log messages in normal operation logging.getLogger("httpx").setLevel(logging.WARNING) @@ -244,7 +249,6 @@ def notify(self, args): except ValueError: self._print_error("Invalid time format. Use HH:MM (e.g., 22:00)") return 1 - mgr.config["dnd_start"] = args.start mgr.config["dnd_end"] = args.end mgr._save_config() @@ -301,7 +305,7 @@ def stack(self, args: argparse.Namespace) -> int: return 1 def _handle_stack_list(self, manager: StackManager) -> int: - """List all available stacks.""" + """List all available stacks (legacy simple stacks).""" stacks = manager.list_stacks() cx_print("\nšŸ“¦ Available Stacks:\n", "info") for stack in stacks: @@ -310,17 +314,20 @@ def _handle_stack_list(self, manager: StackManager) -> int: console.print(f" {stack.get('name', 'Unnamed Stack')}") console.print(f" {stack.get('description', 'No description')}") console.print(f" [dim]({pkg_count} packages)[/dim]\n") - cx_print("Use: cortex stack to install a stack", "info") + cx_print("Use: cortex install --stack --execute to install", "info") return 0 def _handle_stack_describe(self, manager: StackManager, stack_id: str) -> int: - """Describe a specific stack.""" + """Describe a specific stack (legacy simple stacks).""" stack = manager.find_stack(stack_id) if not stack: - self._print_error(f"Stack '{stack_id}' not found. Use --list to see available stacks.") + self._print_error( + f"Stack '{stack_id}' not found. Use 'cortex stack list' to see available stacks." + ) return 1 description = manager.describe_stack(stack_id) console.print(description) + cx_print(f"\nTo install: cortex install --stack {stack_id} --execute", "info") return 0 def _handle_stack_install(self, manager: StackManager, args: argparse.Namespace) -> int: @@ -633,34 +640,36 @@ def install( software: str, execute: bool = False, dry_run: bool = False, + stack: str | None = None, parallel: bool = False, ): - # Validate input first - is_valid, error = validate_install_request(software) - if not is_valid: - self._print_error(error) - return 1 + # Validate input first (only if not using stack) + if not stack: + is_valid, error = validate_install_request(software) + if not is_valid: + self._print_error(error) + return 1 - # Special-case the ml-cpu stack: - # The LLM sometimes generates outdated torch==1.8.1+cpu installs - # which fail on modern Python. For the "pytorch-cpu jupyter numpy pandas" - # combo, force a supported CPU-only PyTorch recipe instead. - normalized = " ".join(software.split()).lower() - - if normalized == "pytorch-cpu jupyter numpy pandas": - software = ( - "pip3 install torch torchvision torchaudio " - "--index-url https://download.pytorch.org/whl/cpu && " - "pip3 install jupyter numpy pandas" - ) + # Special-case the ml-cpu stack: + # The LLM sometimes generates outdated torch==1.8.1+cpu installs + # which fail on modern Python. For the "pytorch-cpu jupyter numpy pandas" + # combo, force a supported CPU-only PyTorch recipe instead. + normalized = " ".join(software.split()).lower() + + if normalized == "pytorch-cpu jupyter numpy pandas": + software = ( + "pip3 install torch torchvision torchaudio " + "--index-url https://download.pytorch.org/whl/cpu && " + "pip3 install jupyter numpy pandas" + ) - api_key = self._get_api_key() - if not api_key: - return 1 + api_key = self._get_api_key() + if not api_key: + return 1 - provider = self._get_provider() - self._debug(f"Using provider: {provider}") - self._debug(f"API key: {api_key[:10]}...{api_key[-4:]}") + provider = self._get_provider() + self._debug(f"Using provider: {provider}") + self._debug(f"API key: {api_key[:10]}...{api_key[-4:]}") # Initialize installation history history = InstallationHistory() @@ -668,18 +677,18 @@ def install( start_time = datetime.now() try: + # If stack is specified, use stack/template system + if stack: + return self._install_from_stack(stack, execute, dry_run) + # Otherwise, use LLM-based installation self._print_status("🧠", "Understanding request...") interpreter = CommandInterpreter(api_key=api_key, provider=provider) - self._print_status("šŸ“¦", "Planning installation...") - for _ in range(10): self._animate_spinner("Analyzing system requirements...") self._clear_line() - commands = interpreter.parse(f"install {software}") - if not commands: self._print_error( "No commands generated. Please try again with a different request." @@ -688,24 +697,20 @@ def install( # Extract packages from commands for tracking packages = history._extract_packages_from_commands(commands) - # Record installation start if execute or dry_run: install_id = history.record_installation( InstallationType.INSTALL, packages, commands, start_time ) - self._print_status("āš™ļø", f"Installing {software}...") print("\nGenerated commands:") for i, cmd in enumerate(commands, 1): print(f" {i}. {cmd}") - if dry_run: print("\n(Dry run mode - commands not executed)") if install_id: history.update_installation(install_id, InstallationStatus.SUCCESS) return 0 - if execute: def progress_callback(current, total, step): @@ -813,19 +818,15 @@ def parallel_log_callback(message: str, level: str = "info"): stop_on_error=True, progress_callback=progress_callback, ) - result = coordinator.execute() - if result.success: self._print_success(f"{software} installed successfully!") print(f"\nCompleted in {result.total_duration:.2f} seconds") - # Record successful installation if install_id: history.update_installation(install_id, InstallationStatus.SUCCESS) print(f"\nšŸ“ Installation recorded (ID: {install_id})") print(f" To rollback: cortex rollback {install_id}") - return 0 else: # Record failed installation @@ -834,7 +835,6 @@ def parallel_log_callback(message: str, level: str = "info"): history.update_installation( install_id, InstallationStatus.FAILED, error_msg ) - if result.failed_step is not None: self._print_error(f"Installation failed at step {result.failed_step + 1}") else: @@ -848,9 +848,7 @@ def parallel_log_callback(message: str, level: str = "info"): else: print("\nTo execute these commands, run with --execute flag") print("Example: cortex install docker --execute") - return 0 - except ValueError as e: if install_id: history.update_installation(install_id, InstallationStatus.FAILED, str(e)) @@ -999,6 +997,406 @@ def rollback(self, install_id: str, dry_run: bool = False): traceback.print_exc() return 1 + def _install_from_stack(self, stack_name: str, execute: bool, dry_run: bool): + """Install from a stack (template).""" + history = InstallationHistory() + install_id = None + start_time = datetime.now() + + try: + template_manager = TemplateManager() + + self._print_status("[*]", f"Loading stack: {stack_name}...") + template = template_manager.load_template(stack_name) + + if not template: + self._print_error(f"Stack '{stack_name}' not found") + self._print_status("[*]", "Available stacks:") + templates = template_manager.list_templates() + for name, info in templates.items(): + print(f" - {name}: {info['description']}") + return 1 + + # Display stack info + print(f"\n{template.name} Stack:") + print(f" {template.description}") + print("\n Packages:") + for pkg in template.packages: + print(f" - {pkg}") + + # Check hardware compatibility + is_compatible, warnings = template_manager.check_hardware_compatibility(template) + if warnings: + print("\n[WARNING] Hardware Compatibility Warnings:") + for warning in warnings: + print(f" - {warning}") + if not is_compatible and not dry_run: + try: + response = input( + "\n[WARNING] Hardware requirements not met. Continue anyway? (y/N): " + ) + if response.lower() != "y": + print("\n[INFO] Installation aborted by user") + return 1 + except (EOFError, KeyboardInterrupt): + # Non-interactive environment or user cancelled + print( + "\n[ERROR] Aborting install: cannot prompt for hardware confirmation in non-interactive mode" + ) + print( + " Use --dry-run to preview commands, or ensure hardware requirements are met" + ) + return 1 + + # Generate commands + self._print_status("[*]", "Generating installation commands...") + commands = template_manager.generate_commands(template) + + if not commands: + self._print_error("No commands generated from template") + return 1 + + # Extract packages for tracking + packages = ( + template.packages + if template.packages + else history._extract_packages_from_commands(commands) + ) + + # Record installation start + if execute or dry_run: + install_id = history.record_installation( + InstallationType.INSTALL, packages, commands, start_time + ) + + print(f"\n[*] Installing {len(packages)} packages...") + print("\nGenerated commands:") + for i, cmd in enumerate(commands, 1): + print(f" {i}. {cmd}") + + if dry_run: + print("\n(Dry run mode - commands not executed)") + if install_id: + history.update_installation(install_id, InstallationStatus.SUCCESS) + return 0 + + if execute: + # Convert template steps to coordinator format if available + if template.steps: + plan = [] + for step in template.steps: + # Add sudo prefix if requires_root is True + command = step.command + if step.requires_root and not command.strip().startswith("sudo "): + command = f"sudo {command}" + + plan.append( + { + "command": command, + "description": step.description, + "rollback": step.rollback, + } + ) + coordinator = InstallationCoordinator.from_plan( + plan, timeout=300, stop_on_error=True + ) + else: + + def progress_callback(current, total, step): + status_emoji = "ā³" + if step.status == StepStatus.SUCCESS: + status_emoji = "āœ…" + elif step.status == StepStatus.FAILED: + status_emoji = "āŒ" + print(f"\n[{current}/{total}] {status_emoji} {step.description}") + print(f" Command: {step.command}") + + coordinator = InstallationCoordinator( + commands=commands, + descriptions=[f"Step {i+1}" for i in range(len(commands))], + timeout=300, + stop_on_error=True, + progress_callback=progress_callback, + ) + + print("\nExecuting commands...") + result = coordinator.execute() + + if result.success: + # Run verification commands if available + if template.verification_commands: + self._print_status("[*]", "Verifying installation...") + verify_results = coordinator.verify_installation( + template.verification_commands + ) + all_passed = all(verify_results.values()) + if not all_passed: + print("\n[WARNING] Some verification checks failed:") + for cmd, passed in verify_results.items(): + status = "[OK]" if passed else "[FAIL]" + print(f" {status} {cmd}") + + # Run post-install commands once + if template.post_install: + self._print_status("[*]", "Running post-installation steps...") + print("\n[*] Post-installation information:") + for cmd in template.post_install: + subprocess.run(cmd, shell=True) + + self._print_success(f"{template.name} stack ready!") + print(f"\nCompleted in {result.total_duration:.2f} seconds") + + # Record successful installation + if install_id: + history.update_installation(install_id, InstallationStatus.SUCCESS) + print(f"\n[*] Installation recorded (ID: {install_id})") + print(f" To rollback: cortex rollback {install_id}") + + return 0 + else: + # Record failed installation + if install_id: + error_msg = result.error_message or "Installation failed" + history.update_installation( + install_id, InstallationStatus.FAILED, error_msg + ) + + if result.failed_step is not None: + self._print_error(f"Installation failed at step {result.failed_step + 1}") + else: + self._print_error("Installation failed") + if result.error_message: + print(f" Error: {result.error_message}", file=sys.stderr) + if install_id: + print(f"\nšŸ“ Installation recorded (ID: {install_id})") + print(f" View details: cortex history show {install_id}") + return 1 + else: + print("\nTo execute these commands, run with --execute flag") + print(f"Example: cortex install --stack {stack_name} --execute") + + return 0 + + except ValueError as e: + if install_id: + history.update_installation(install_id, InstallationStatus.FAILED, str(e)) + self._print_error(str(e)) + return 1 + except (RuntimeError, OSError, subprocess.SubprocessError) as e: + if install_id: + history.update_installation(install_id, InstallationStatus.FAILED, str(e)) + self._print_error(f"Unexpected error: {str(e)}") + return 1 + + def stack_list(self): + """List all available stacks.""" + try: + template_manager = TemplateManager() + templates = template_manager.list_templates() + + if not templates: + print("No stacks found.") + return 0 + + print("\nAvailable Stacks:") + print("=" * 80) + print(f"{'Name':<20} {'Version':<12} {'Type':<12} {'Description':<35}") + print("=" * 80) + + for name, info in sorted(templates.items()): + desc = ( + info["description"][:33] + "..." + if len(info["description"]) > 35 + else info["description"] + ) + print(f"{name:<20} {info['version']:<12} {info['type']:<12} {desc:<35}") + + print(f"\nTotal: {len(templates)} stacks") + print("\nTo install a stack:") + print(" cortex install --stack --dry-run # Preview") + print(" cortex install --stack --execute # Install") + return 0 + except Exception as e: + self._print_error(f"Failed to list stacks: {str(e)}") + return 1 + + def stack_describe(self, name: str): + """Show detailed information about a stack.""" + try: + template_manager = TemplateManager() + template = template_manager.load_template(name) + + if not template: + self._print_error(f"Stack '{name}' not found") + print("\nAvailable stacks:") + templates = template_manager.list_templates() + for stack_name in sorted(templates.keys()): + print(f" - {stack_name}") + return 1 + + # Display stack details + print(f"\nšŸ“¦ Stack: {template.name}") + print(f" {template.description}") + print(f" Version: {template.version}") + if template.author: + print(f" Author: {template.author}") + + print("\n Packages:") + for pkg in template.packages: + print(f" - {pkg}") + + # Show hardware requirements if present + if template.hardware_requirements: + hw = template.hardware_requirements + print("\n Hardware Requirements:") + if hw.min_ram_mb: + print(f" - Minimum RAM: {hw.min_ram_mb}MB") + if hw.min_cores: + print(f" - Minimum CPU cores: {hw.min_cores}") + if hw.min_storage_mb: + print(f" - Minimum storage: {hw.min_storage_mb}MB") + if hw.requires_gpu: + gpu_info = "Required" + if hw.gpu_vendor: + gpu_info += f" ({hw.gpu_vendor})" + print(f" - GPU: {gpu_info}") + if hw.requires_cuda: + cuda_info = "Required" + if hw.min_cuda_version: + cuda_info += f" (>= {hw.min_cuda_version})" + print(f" - CUDA: {cuda_info}") + + # Show verification commands if present + if template.verification_commands: + print("\n Verification commands:") + for cmd in template.verification_commands: + print(f" $ {cmd}") + + print("\n To install this stack:") + print(f" cortex install --stack {name} --dry-run # Preview") + print(f" cortex install --stack {name} --execute # Install") + + return 0 + except Exception as e: + self._print_error(f"Failed to describe stack: {str(e)}") + return 1 + + def stack_create(self, name: str, interactive: bool = True): + """Create a new stack interactively.""" + try: + print(f"\n[*] Creating stack: {name}") + + if interactive: + description = input("Description: ").strip() + if not description: + self._print_error("Description is required") + return 1 + + version = input("Version (default: 1.0.0): ").strip() or "1.0.0" + author = input("Author (optional): ").strip() or None + + print("\nEnter packages (one per line, empty line to finish):") + packages = [] + while True: + pkg = input(" Package: ").strip() + if not pkg: + break + packages.append(pkg) + + # Create stack template + from cortex.templates import HardwareRequirements, Template + + template = Template( + name=name, + description=description, + version=version, + author=author, + packages=packages, + ) + + # Ask about hardware requirements + print("\nHardware Requirements (optional):") + min_ram = input(" Minimum RAM (MB, optional): ").strip() + min_cores = input(" Minimum CPU cores (optional): ").strip() + min_storage = input(" Minimum storage (MB, optional): ").strip() + + if min_ram or min_cores or min_storage: + try: + hw_req = HardwareRequirements( + min_ram_mb=int(min_ram) if min_ram else None, + min_cores=int(min_cores) if min_cores else None, + min_storage_mb=int(min_storage) if min_storage else None, + ) + except ValueError: + self._print_error("Hardware requirements must be numeric values") + return 1 + template.hardware_requirements = hw_req + + # Save stack + template_manager = TemplateManager() + template_path = template_manager.save_template(template, name) + + self._print_success(f"Stack '{name}' created successfully!") + print(f" Saved to: {template_path}") + return 0 + else: + self._print_error("Non-interactive stack creation not yet supported") + return 1 + + except Exception as e: + self._print_error(f"Failed to create stack: {str(e)}") + return 1 + + def stack_import(self, file_path: str, name: str | None = None): + """Import a stack from a file.""" + try: + template_manager = TemplateManager() + template = template_manager.import_template(file_path, name) + + # Save to user stacks + save_name = name or template.name + template_path = template_manager.save_template(template, save_name) + + self._print_success(f"Stack '{save_name}' imported successfully!") + print(f" Saved to: {template_path}") + return 0 + except Exception as e: + self._print_error(f"Failed to import stack: {str(e)}") + return 1 + + def stack_export(self, name: str, file_path: str, format: str = "yaml"): + """Export a stack to a file.""" + try: + template_manager = TemplateManager() + template_format = ( + TemplateFormat.YAML if format.lower() == "yaml" else TemplateFormat.JSON + ) + export_path = template_manager.export_template(name, file_path, template_format) + + self._print_success(f"Stack '{name}' exported successfully!") + print(f" Saved to: {export_path}") + return 0 + except Exception as e: + self._print_error(f"Failed to export stack: {str(e)}") + return 1 + + # NOTE: User preferences module not yet implemented + # def _get_prefs_manager(self): + # """Lazy initialize preferences manager""" + # if self.prefs_manager is None: + # self.prefs_manager = PreferencesManager() + # return self.prefs_manager + # + # def check_pref(self, key: str | None = None): + # """Check/display user preferences""" + # manager = self._get_prefs_manager() + # ... + # + # def edit_pref(self, action: str, key: str | None = None, value: str | None = None): + # """Edit user preferences (add/set, delete/remove, list)""" + # manager = self._get_prefs_manager() + # ... + def status(self): """Show comprehensive system status and run health checks""" from cortex.doctor import SystemDoctor @@ -1641,20 +2039,29 @@ def show_rich_help(): table.add_row("demo", "See Cortex in action") table.add_row("wizard", "Configure API key") table.add_row("status", "System status") - table.add_row("install ", "Install software") + table.add_row("install ", "Install software using natural language") + table.add_row("install --stack ", "Install a pre-configured stack") table.add_row("import ", "Import deps from package files") - table.add_row("history", "View history") - table.add_row("rollback ", "Undo installation") + table.add_row("history", "View installation history") + table.add_row("rollback ", "Undo an installation") + table.add_row("stack list", "List available stacks") + table.add_row("stack create ", "Create a custom stack") + table.add_row("stack export/import", "Share stacks with others") table.add_row("notify", "Manage desktop notifications") table.add_row("env", "Manage environment variables") table.add_row("cache stats", "Show LLM cache statistics") - table.add_row("stack ", "Install the stack") table.add_row("docker permissions", "Fix Docker bind-mount permissions") table.add_row("sandbox ", "Test packages in Docker sandbox") table.add_row("doctor", "System health check") console.print(table) console.print() + + console.print("[bold cyan]Quick Examples:[/bold cyan]") + console.print(" cortex install docker --execute") + console.print(" cortex install --stack lamp --dry-run") + console.print(" cortex stack list") + console.print() console.print("[dim]Learn more: https://cortexlinux.com/docs[/dim]") @@ -1708,6 +2115,36 @@ def main(): prog="cortex", description="AI-powered Linux command interpreter", formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Natural language installation + cortex install docker + cortex install docker --execute + cortex install "python 3.11 with pip" + cortex install nginx --dry-run + + # Stack-based installation + cortex install --stack lamp --dry-run # Preview LAMP stack + cortex install --stack lamp --execute # Install LAMP stack + cortex install --stack ml-ai --execute # Install ML/AI stack + + # Stack management + cortex stack list # List all stacks + cortex stack describe lamp # Show stack details + cortex stack create my-stack # Create custom stack + cortex stack export lamp my-lamp.yaml # Export for sharing + cortex stack import my-lamp.yaml # Import a stack + + # History and rollback + cortex history + cortex history show + cortex rollback + +Environment Variables: + OPENAI_API_KEY OpenAI API key for GPT-4 + ANTHROPIC_API_KEY Anthropic API key for Claude + CORTEX_PROVIDER LLM provider (claude, openai, ollama) + """, ) # Global flags @@ -1746,10 +2183,48 @@ def main(): ask_parser.add_argument("question", type=str, help="Natural language question") # Install command - install_parser = subparsers.add_parser("install", help="Install software") - install_parser.add_argument("software", type=str, help="Software to install") - install_parser.add_argument("--execute", action="store_true", help="Execute commands") - install_parser.add_argument("--dry-run", action="store_true", help="Show commands only") + install_parser = subparsers.add_parser( + "install", + help="Install software using natural language or stack", + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""Install software packages using natural language or pre-configured stacks. + +Cortex uses AI to understand your installation requests and generates +the appropriate commands for your system.""", + epilog=""" +Examples: + # Natural language installation + cortex install docker + cortex install "python 3.11 with pip" + cortex install nginx --dry-run + cortex install docker --execute + + # Stack-based installation (pre-configured bundles) + cortex install --stack lamp --dry-run # Preview LAMP stack + cortex install --stack lamp --execute # Install LAMP stack + cortex install --stack mern --execute # Install MERN stack + cortex install --stack ml-ai --execute # Install ML/AI stack + +Available stacks: lamp, mean, mern, ml-ai, devops +Use 'cortex stack list' to see all available stacks. +""", + ) + install_group = install_parser.add_mutually_exclusive_group(required=True) + install_group.add_argument( + "software", type=str, nargs="?", help="Software to install (natural language)" + ) + install_group.add_argument( + "--stack", + type=str, + metavar="NAME", + help="Install from a pre-configured stack (e.g., lamp, mean, mern, ml-ai, devops)", + ) + install_parser.add_argument( + "--execute", action="store_true", help="Execute the generated commands" + ) + install_parser.add_argument( + "--dry-run", action="store_true", help="Show commands without executing" + ) install_parser.add_argument( "--parallel", action="store_true", @@ -1792,9 +2267,11 @@ def main(): history_parser.add_argument("show_id", nargs="?") # Rollback command - rollback_parser = subparsers.add_parser("rollback", help="Rollback installation") - rollback_parser.add_argument("id", help="Installation ID") - rollback_parser.add_argument("--dry-run", action="store_true") + rollback_parser = subparsers.add_parser("rollback", help="Rollback an installation") + rollback_parser.add_argument("id", help="Installation ID to rollback") + rollback_parser.add_argument( + "--dry-run", action="store_true", help="Show rollback actions without executing" + ) # --- New Notify Command --- notify_parser = subparsers.add_parser("notify", help="Manage desktop notifications") @@ -1815,17 +2292,149 @@ def main(): send_parser.add_argument("--actions", nargs="*", help="Action buttons") # -------------------------- - # Stack command - stack_parser = subparsers.add_parser("stack", help="Manage pre-built package stacks") - stack_parser.add_argument( - "name", nargs="?", help="Stack name to install (ml, ml-cpu, webdev, devops, data)" + # Template commands - DEPRECATED: Use 'cortex stack' instead + # Kept for backward compatibility, redirects to stack commands + template_parser = subparsers.add_parser( + "template", + help="[DEPRECATED] Use 'cortex stack' instead", + description="DEPRECATED: This command has been renamed to 'cortex stack'.\n" + "Please use 'cortex stack list', 'cortex stack create', etc.", + ) + template_subs = template_parser.add_subparsers(dest="template_action", help="Template actions") + template_subs.add_parser("list", help="[DEPRECATED] Use 'cortex stack list'") + + template_create_parser = template_subs.add_parser( + "create", help="[DEPRECATED] Use 'cortex stack create'" + ) + template_create_parser.add_argument("name", help="Template name") + + template_import_parser = template_subs.add_parser( + "import", help="[DEPRECATED] Use 'cortex stack import'" + ) + template_import_parser.add_argument("file_path", help="Path to template file") + template_import_parser.add_argument("--name", help="Override template name") + + template_export_parser = template_subs.add_parser( + "export", help="[DEPRECATED] Use 'cortex stack export'" + ) + template_export_parser.add_argument("name", help="Template name") + template_export_parser.add_argument("file_path", help="Output file path") + template_export_parser.add_argument( + "--format", choices=["yaml", "json"], default="yaml", help="Export format" + ) + + # Stack command - enhanced with create/import/export subcommands + stack_parser = subparsers.add_parser( + "stack", + help="Manage installation stacks for common development environments", + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""Manage installation stacks for common development environments. + +Stacks are pre-configured bundles of packages and tools that can be installed +together. Cortex provides built-in stacks for common use cases and supports +custom stack creation.""", + epilog=""" +Examples: + # List all available stacks + cortex stack list + + # Show details about a specific stack + cortex stack describe lamp + + # Install a stack (use install command) + cortex install --stack lamp --dry-run # Preview first + cortex install --stack lamp --execute # Install + + # Create a custom stack interactively + cortex stack create my-dev-stack + + # Export a stack to share with others + cortex stack export lamp my-lamp.yaml + + # Import a stack from a file + cortex stack import my-lamp.yaml + cortex stack import my-lamp.yaml --name custom-lamp + +Built-in stacks: + lamp - Linux, Apache, MySQL, PHP web server stack + mean - MongoDB, Express.js, Angular, Node.js + mern - MongoDB, Express.js, React, Node.js + ml-ai - Machine Learning with Python, TensorFlow, PyTorch + devops - Docker, Kubernetes, Terraform, Ansible + +Use 'cortex stack list' for a complete list with descriptions. +""", + ) + stack_subs = stack_parser.add_subparsers(dest="stack_action", help="Stack actions") + + # stack list + stack_list_parser = stack_subs.add_parser( + "list", + help="List all available stacks", + description="Display all available stacks with their versions and descriptions.", + epilog=""" +Examples: + cortex stack list +""", + ) + + # stack describe + stack_describe_parser = stack_subs.add_parser( + "describe", + help="Show detailed information about a stack", + description="Display detailed information about a specific stack including packages and requirements.", + epilog=""" +Examples: + cortex stack describe lamp + cortex stack describe ml-ai +""", + ) + stack_describe_parser.add_argument("name", help="Stack name to describe") + + # stack create + stack_create_parser = stack_subs.add_parser( + "create", + help="Create a new custom stack interactively", + description="Create a new custom stack with packages and hardware requirements.", + epilog=""" +Examples: + cortex stack create my-web-stack + cortex stack create ml-custom +""", + ) + stack_create_parser.add_argument("name", help="Name for the new stack") + + # stack import [--name NAME] + stack_import_parser = stack_subs.add_parser( + "import", + help="Import a stack from a YAML/JSON file", + description="Import a stack definition from a YAML or JSON file.", + epilog=""" +Examples: + cortex stack import my-stack.yaml + cortex stack import team-stack.json --name my-team-stack +""", + ) + stack_import_parser.add_argument("file_path", help="Path to stack file (YAML or JSON)") + stack_import_parser.add_argument("--name", help="Override the stack name") + + # stack export [--format FORMAT] + stack_export_parser = stack_subs.add_parser( + "export", + help="Export a stack to a file for sharing", + description="Export a stack definition to a YAML or JSON file.", + epilog=""" +Examples: + cortex stack export lamp my-lamp.yaml + cortex stack export ml-ai ml-stack.json --format json +""", ) - stack_group = stack_parser.add_mutually_exclusive_group() - stack_group.add_argument("--list", "-l", action="store_true", help="List all available stacks") - stack_group.add_argument("--describe", "-d", metavar="STACK", help="Show details about a stack") - stack_parser.add_argument( - "--dry-run", action="store_true", help="Show what would be installed (requires stack name)" + stack_export_parser.add_argument("name", help="Stack name to export") + stack_export_parser.add_argument("file_path", help="Output file path") + stack_export_parser.add_argument( + "--format", choices=["yaml", "json"], default="yaml", help="Export format (default: yaml)" ) + # Cache commands cache_parser = subparsers.add_parser("cache", help="Cache operations") cache_subs = cache_parser.add_subparsers(dest="cache_action", help="Cache actions") @@ -2000,18 +2609,45 @@ def main(): elif args.command == "ask": return cli.ask(args.question) elif args.command == "install": - return cli.install( - args.software, - execute=args.execute, - dry_run=args.dry_run, - parallel=args.parallel, - ) + if args.stack: + return cli.install("", execute=args.execute, dry_run=args.dry_run, stack=args.stack) + else: + return cli.install( + args.software, + execute=args.execute, + dry_run=args.dry_run, + parallel=args.parallel, + ) elif args.command == "import": return cli.import_deps(args) elif args.command == "history": return cli.history(limit=args.limit, status=args.status, show_id=args.show_id) elif args.command == "rollback": return cli.rollback(args.id, dry_run=args.dry_run) + elif args.command == "template": + # DEPRECATED: Redirect to stack commands with warning + cx_print("āš ļø 'cortex template' is deprecated. Use 'cortex stack' instead.", "warning") + if args.template_action == "list": + cx_print(" Use: cortex stack list", "info") + return cli.stack_list() + elif args.template_action == "create": + cx_print(f" Use: cortex stack create {args.name}", "info") + return cli.stack_create(args.name) + elif args.template_action == "import": + cx_print(f" Use: cortex stack import {args.file_path}", "info") + return cli.stack_import(args.file_path, args.name) + elif args.template_action == "export": + cx_print(f" Use: cortex stack export {args.name} {args.file_path}", "info") + return cli.stack_export(args.name, args.file_path, args.format) + else: + cx_print(" Use: cortex stack --help", "info") + parser.print_help() + return 1 + # NOTE: User preferences commands not yet implemented + # elif args.command == "check-pref": + # return cli.check_pref(key=args.key) + # elif args.command == "edit-pref": + # return cli.edit_pref(action=args.action, key=args.key, value=args.value) # Handle the new notify command elif args.command == "notify": return cli.notify(args) diff --git a/cortex/templates.py b/cortex/templates.py new file mode 100644 index 00000000..2a371946 --- /dev/null +++ b/cortex/templates.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +""" +Template System for Cortex Linux Installation Templates + +Supports pre-built templates for common development stacks (LAMP, MEAN, MERN, etc.) +and custom template creation, validation, and hardware-aware selection. +""" + +import json +import os +import sys +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +import yaml + +# Add parent directory to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from cortex.hwprofiler import HardwareProfiler +from cortex.packages import PackageManager, PackageManagerType + + +class TemplateFormat(Enum): + """Supported template formats.""" + + YAML = "yaml" + JSON = "json" + + +@dataclass +class HardwareRequirements: + """Hardware requirements for a template.""" + + min_ram_mb: int | None = None + min_cores: int | None = None + min_storage_mb: int | None = None + requires_gpu: bool = False + gpu_vendor: str | None = None # "NVIDIA", "AMD", "Intel" + requires_cuda: bool = False + min_cuda_version: str | None = None + + +@dataclass +class InstallationStep: + """A single installation step in a template.""" + + command: str + description: str + rollback: str | None = None + verify: str | None = None + requires_root: bool = True + + +@dataclass +class Template: + """Represents an installation template.""" + + name: str + description: str + version: str + author: str | None = None + packages: list[str] = field(default_factory=list) + steps: list[InstallationStep] = field(default_factory=list) + hardware_requirements: HardwareRequirements | None = None + post_install: list[str] = field(default_factory=list) + verification_commands: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Convert template to dictionary.""" + result = { + "name": self.name, + "description": self.description, + "version": self.version, + "packages": self.packages, + "post_install": self.post_install, + "verification_commands": self.verification_commands, + "metadata": self.metadata, + } + + if self.author: + result["author"] = self.author + + if self.steps: + result["steps"] = [ + { + "command": step.command, + "description": step.description, + "rollback": step.rollback, + "verify": step.verify, + "requires_root": step.requires_root, + } + for step in self.steps + ] + + if self.hardware_requirements: + hw = self.hardware_requirements + result["hardware_requirements"] = { + "min_ram_mb": hw.min_ram_mb, + "min_cores": hw.min_cores, + "min_storage_mb": hw.min_storage_mb, + "requires_gpu": hw.requires_gpu, + "gpu_vendor": hw.gpu_vendor, + "requires_cuda": hw.requires_cuda, + "min_cuda_version": hw.min_cuda_version, + } + + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Template": + """Create template from dictionary.""" + # Parse hardware requirements + hw_req = None + if "hardware_requirements" in data: + hw_data = data["hardware_requirements"] + hw_req = HardwareRequirements( + min_ram_mb=hw_data.get("min_ram_mb"), + min_cores=hw_data.get("min_cores"), + min_storage_mb=hw_data.get("min_storage_mb"), + requires_gpu=hw_data.get("requires_gpu", False), + gpu_vendor=hw_data.get("gpu_vendor"), + requires_cuda=hw_data.get("requires_cuda", False), + min_cuda_version=hw_data.get("min_cuda_version"), + ) + + # Parse installation steps + steps = [] + if "steps" in data: + for step_data in data["steps"]: + steps.append( + InstallationStep( + command=step_data["command"], + description=step_data.get("description", ""), + rollback=step_data.get("rollback"), + verify=step_data.get("verify"), + requires_root=step_data.get("requires_root", True), + ) + ) + + return cls( + name=data["name"], + description=data["description"], + version=data.get("version", "1.0.0"), + author=data.get("author"), + packages=data.get("packages", []), + steps=steps, + hardware_requirements=hw_req, + post_install=data.get("post_install", []), + verification_commands=data.get("verification_commands", []), + metadata=data.get("metadata", {}), + ) + + +class TemplateValidator: + """Validates template structure and content.""" + + REQUIRED_FIELDS = ["name", "description", "version"] + REQUIRED_STEP_FIELDS = ["command", "description"] + + # Allowed post_install commands (whitelist for security) + ALLOWED_POST_INSTALL_COMMANDS = { + "echo", # Safe echo commands + } + + # Dangerous shell metacharacters that should be rejected + DANGEROUS_SHELL_CHARS = [";", "|", "&", ">", "<", "`", "\\"] + + @staticmethod + def _validate_post_install_commands(post_install: list[str]) -> list[str]: + """ + Validate post_install commands for security. + + Returns: + List of validation errors + """ + errors = [] + + for i, cmd in enumerate(post_install): + # Validate that cmd is a string (handle malformed data) + if not isinstance(cmd, str): + errors.append(f"post_install[{i}]: Must be a string, got {type(cmd).__name__}") + continue + + if not cmd or not cmd.strip(): + continue + + cmd_stripped = cmd.strip() + + # Check for dangerous shell metacharacters + for char in TemplateValidator.DANGEROUS_SHELL_CHARS: + if char in cmd_stripped: + errors.append( + f"post_install[{i}]: Contains dangerous shell character '{char}'. " + "Only safe commands like 'echo' are allowed." + ) + break + + # Check for command substitution patterns + if "$(" in cmd_stripped or "`" in cmd_stripped: + # Allow $(...) in echo commands for version checks (built-in templates use this) + if not cmd_stripped.startswith("echo "): + errors.append( + f"post_install[{i}]: Command substitution only allowed in 'echo' commands" + ) + + # Check for wildcards/globs + if "*" in cmd_stripped or "?" in cmd_stripped: + if not cmd_stripped.startswith("echo "): + errors.append(f"post_install[{i}]: Wildcards only allowed in 'echo' commands") + + # Whitelist check - only allow echo commands + if not cmd_stripped.startswith("echo "): + errors.append( + f"post_install[{i}]: Only 'echo' commands are allowed in post_install. " + f"Found: {cmd_stripped[:50]}" + ) + + return errors + + @staticmethod + def validate(template: Template) -> tuple[bool, list[str]]: + """ + Validate a template. + + Returns: + Tuple of (is_valid, list_of_errors) + """ + errors = [] + + # Check required fields + if not template.name: + errors.append("Template name is required") + if not template.description: + errors.append("Template description is required") + if not template.version: + errors.append("Template version is required") + + # Validate steps + for i, step in enumerate(template.steps): + if not step.command: + errors.append(f"Step {i+1}: command is required") + if not step.description: + errors.append(f"Step {i+1}: description is required") + + # Validate packages list + if not template.packages and not template.steps: + errors.append("Template must have either packages or steps defined") + + # Validate hardware requirements + if template.hardware_requirements: + hw = template.hardware_requirements + if hw.min_ram_mb is not None and hw.min_ram_mb < 0: + errors.append("min_ram_mb must be non-negative") + if hw.min_cores is not None and hw.min_cores < 0: + errors.append("min_cores must be non-negative") + if hw.min_storage_mb is not None and hw.min_storage_mb < 0: + errors.append("min_storage_mb must be non-negative") + if hw.requires_cuda and not hw.requires_gpu: + errors.append("requires_cuda is true but requires_gpu is false") + + # Validate post_install commands for security + if template.post_install: + post_install_errors = TemplateValidator._validate_post_install_commands( + template.post_install + ) + errors.extend(post_install_errors) + + return len(errors) == 0, errors + + +class TemplateManager: + """Manages installation templates.""" + + def __init__(self, templates_dir: str | None = None): + """ + Initialize template manager. + + Args: + templates_dir: Directory containing templates (defaults to built-in templates) + """ + if templates_dir: + self.templates_dir = Path(templates_dir) + else: + # Default to built-in templates directory + base_dir = Path(__file__).parent + self.templates_dir = base_dir / "templates" + + self.user_templates_dir = Path.home() / ".cortex" / "templates" + self.user_templates_dir.mkdir(parents=True, exist_ok=True) + + self._templates_cache: dict[str, Template] = {} + self._hardware_profiler = HardwareProfiler() + self._package_manager = PackageManager() + + def _get_template_path(self, name: str) -> Path | None: + """Find template file by name.""" + # Check user templates first + for ext in [".yaml", ".yml", ".json"]: + user_path = self.user_templates_dir / f"{name}{ext}" + if user_path.exists(): + return user_path + + # Check built-in templates + for ext in [".yaml", ".yml", ".json"]: + builtin_path = self.templates_dir / f"{name}{ext}" + if builtin_path.exists(): + return builtin_path + + return None + + def load_template(self, name: str) -> Template | None: + """Load a template by name.""" + if name in self._templates_cache: + return self._templates_cache[name] + + template_path = self._get_template_path(name) + if not template_path: + return None + + try: + with open(template_path, encoding="utf-8") as f: + if template_path.suffix in [".yaml", ".yml"]: + data = yaml.safe_load(f) + else: + data = json.load(f) + + template = Template.from_dict(data) + self._templates_cache[name] = template + return template + except Exception as e: + raise ValueError(f"Failed to load template {name}: {str(e)}") + + def save_template( + self, + template: Template, + name: str | None = None, + format: TemplateFormat = TemplateFormat.YAML, + ) -> Path: + """ + Save a template to user templates directory. + + Args: + template: Template to save + name: Template name (defaults to template.name) + format: File format (YAML or JSON) + + Returns: + Path to saved template file + """ + # Validate template + is_valid, errors = TemplateValidator.validate(template) + if not is_valid: + raise ValueError(f"Template validation failed: {', '.join(errors)}") + + template_name = name or template.name + ext = ".yaml" if format == TemplateFormat.YAML else ".json" + template_path = self.user_templates_dir / f"{template_name}{ext}" + + data = template.to_dict() + + with open(template_path, "w", encoding="utf-8") as f: + if format == TemplateFormat.YAML: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + else: + json.dump(data, f, indent=2) + + return template_path + + def list_templates(self) -> dict[str, dict[str, Any]]: + """List all available templates.""" + templates = {} + + # List built-in templates (load directly from file to avoid user overrides) + if self.templates_dir.exists(): + for ext in [".yaml", ".yml", ".json"]: + for template_file in self.templates_dir.glob(f"*{ext}"): + name = template_file.stem + if name in templates: + # Skip duplicate names across extensions + continue + try: + with open(template_file, encoding="utf-8") as f: + if template_file.suffix in [".yaml", ".yml"]: + data = yaml.safe_load(f) + else: + data = json.load(f) + template = Template.from_dict(data) + templates[name] = { + "name": template.name, + "description": template.description, + "version": template.version, + "author": template.author, + "type": "built-in", + "path": str(template_file), + } + except Exception: + # Ignore malformed built-ins but continue listing others + pass + + # List user templates + if self.user_templates_dir.exists(): + for ext in [".yaml", ".yml", ".json"]: + for template_file in self.user_templates_dir.glob(f"*{ext}"): + name = template_file.stem + if name not in templates: # Don't override built-in + try: + template = self.load_template(name) + if template: + templates[name] = { + "name": template.name, + "description": template.description, + "version": template.version, + "author": template.author, + "type": "user", + "path": str(template_file), + } + except Exception: + pass + + return templates + + def check_hardware_compatibility(self, template: Template) -> tuple[bool, list[str]]: + """ + Check if current hardware meets template requirements. + + Returns: + Tuple of (is_compatible, list_of_warnings) + """ + if not template.hardware_requirements: + return True, [] + + hw_profile = self._hardware_profiler.profile() + hw_req = template.hardware_requirements + warnings = [] + + # Check RAM + if hw_req.min_ram_mb: + available_ram = hw_profile.get("ram", 0) + if available_ram < hw_req.min_ram_mb: + warnings.append( + f"Insufficient RAM: {available_ram}MB available, " + f"{hw_req.min_ram_mb}MB required" + ) + + # Check CPU cores + if hw_req.min_cores: + available_cores = hw_profile.get("cpu", {}).get("cores", 0) + if available_cores < hw_req.min_cores: + warnings.append( + f"Insufficient CPU cores: {available_cores} available, " + f"{hw_req.min_cores} required" + ) + + # Check storage + if hw_req.min_storage_mb: + total_storage = sum(s.get("size", 0) for s in hw_profile.get("storage", [])) + if total_storage < hw_req.min_storage_mb: + warnings.append( + f"Insufficient storage: {total_storage}MB available, " + f"{hw_req.min_storage_mb}MB required" + ) + + # Check GPU requirements + if hw_req.requires_gpu: + gpus = hw_profile.get("gpu", []) + if not gpus: + warnings.append("GPU required but not detected") + elif hw_req.gpu_vendor: + vendor_match = any(g.get("vendor") == hw_req.gpu_vendor for g in gpus) + if not vendor_match: + warnings.append(f"{hw_req.gpu_vendor} GPU required but not found") + + # Check CUDA requirements + if hw_req.requires_cuda: + gpus = hw_profile.get("gpu", []) + cuda_found = False + for gpu in gpus: + if gpu.get("vendor") == "NVIDIA" and gpu.get("cuda"): + cuda_version = gpu.get("cuda", "") + if hw_req.min_cuda_version: + # Simple version comparison + try: + gpu_cuda = tuple(map(int, cuda_version.split("."))) + req_cuda = tuple(map(int, hw_req.min_cuda_version.split("."))) + if gpu_cuda >= req_cuda: + cuda_found = True + break + except ValueError: + # If version parsing fails, just check if CUDA exists + cuda_found = True + break + else: + cuda_found = True + break + + if not cuda_found: + warnings.append(f"CUDA {hw_req.min_cuda_version or ''} required but not found") + + is_compatible = len(warnings) == 0 + return is_compatible, warnings + + def generate_commands(self, template: Template) -> list[str]: + """ + Generate installation commands from template. + + Returns: + List of installation commands with sudo prefix for root-required commands + """ + commands = [] + + # If template has explicit steps, use those + if template.steps: + for step in template.steps: + # Add sudo prefix if requires_root is True + if step.requires_root: + # Don't add sudo if it's already there + if not step.command.strip().startswith("sudo "): + commands.append(f"sudo {step.command}") + else: + commands.append(step.command) + else: + commands.append(step.command) + # Otherwise, generate from packages + elif template.packages: + # Use package manager to generate commands + pm = PackageManager() + package_list = " ".join(template.packages) + try: + commands = pm.parse(f"install {package_list}") + except ValueError: + # Fallback: direct apt/yum install with sudo + pm_type = pm.pm_type.value + commands = [f"sudo {pm_type} install -y {' '.join(template.packages)}"] + + return commands + + def import_template(self, file_path: str, name: str | None = None) -> Template: + """ + Import a template from a file. + + Args: + file_path: Path to template file + name: Optional new name for the template + + Returns: + Loaded template + """ + template_path = Path(file_path) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found: {file_path}") + + try: + with open(template_path, encoding="utf-8") as f: + if template_path.suffix in [".yaml", ".yml"]: + data = yaml.safe_load(f) + else: + data = json.load(f) + + template = Template.from_dict(data) + + # Override name if provided + if name: + template.name = name + + # Validate + is_valid, errors = TemplateValidator.validate(template) + if not is_valid: + raise ValueError(f"Template validation failed: {', '.join(errors)}") + + return template + except Exception as e: + raise ValueError(f"Failed to import template: {str(e)}") + + def export_template( + self, name: str, file_path: str, format: TemplateFormat = TemplateFormat.YAML + ) -> Path: + """ + Export a template to a file. + + Args: + name: Template name + file_path: Destination file path + format: File format + + Returns: + Path to exported file + """ + template = self.load_template(name) + if not template: + raise ValueError(f"Template not found: {name}") + + export_path = Path(file_path) + data = template.to_dict() + + with open(export_path, "w", encoding="utf-8") as f: + if format == TemplateFormat.YAML: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + else: + json.dump(data, f, indent=2) + + return export_path diff --git a/cortex/templates/README.md b/cortex/templates/README.md new file mode 100644 index 00000000..44dd788b --- /dev/null +++ b/cortex/templates/README.md @@ -0,0 +1,65 @@ +# Installation Templates + +This directory contains built-in installation templates for common development stacks. + +## Available Templates + +- **lamp.yaml** - LAMP Stack (Linux, Apache, MySQL, PHP) +- **mean.yaml** - MEAN Stack (MongoDB, Express.js, Angular, Node.js) +- **mern.yaml** - MERN Stack (MongoDB, Express.js, React, Node.js) +- **ml-ai.yaml** - Machine Learning / AI Stack +- **devops.yaml** - DevOps Stack (Docker, Kubernetes, Terraform, etc.) + +## Usage + +```bash +# List all templates +cortex template list + +# Install from template +cortex install --template lamp --execute + +# Create custom template +cortex template create my-stack + +# Import template +cortex template import my-template.yaml + +# Export template +cortex template export lamp my-lamp.yaml +``` + +## Template Format + +Templates are defined in YAML format with the following structure: + +```yaml +name: Template Name +description: Template description +version: 1.0.0 +author: Author Name (optional) + +packages: + - package1 + - package2 + +steps: + - command: apt update + description: Update packages + requires_root: true + rollback: (optional) + +hardware_requirements: + min_ram_mb: 2048 + min_cores: 2 + min_storage_mb: 10240 + +post_install: + - echo "Installation complete" + +verification_commands: + - package --version +``` + +See [TEMPLATES.md](../../docs/TEMPLATES.md) for complete documentation. + diff --git a/cortex/templates/devops.yaml b/cortex/templates/devops.yaml new file mode 100644 index 00000000..f9c87515 --- /dev/null +++ b/cortex/templates/devops.yaml @@ -0,0 +1,93 @@ +name: DevOps Stack +description: Complete DevOps toolchain with Docker, Kubernetes, Terraform, Ansible, and CI/CD tools +version: 1.0.0 +author: Cortex Linux +packages: + - docker.io + - docker-compose + - kubectl + - git + - curl + - wget + - ansible + - terraform + - jenkins + - gitlab-runner + +steps: + - command: apt update + description: Update package lists + requires_root: true + - command: apt install -y apt-transport-https ca-certificates curl gnupg lsb-release + description: Install prerequisites for Docker + requires_root: true + - command: install -m 0755 -d /etc/apt/keyrings + description: Create keyrings directory + requires_root: true + - command: curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + description: Add Docker GPG key + requires_root: true + - command: chmod a+r /etc/apt/keyrings/docker.gpg + description: Set key permissions + requires_root: true + - command: echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + description: Add Docker repository + requires_root: true + - command: apt update + description: Update package lists with Docker repo + requires_root: true + - command: apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + description: Install Docker + requires_root: true + - command: systemctl start docker + description: Start Docker service + requires_root: true + rollback: systemctl stop docker + - command: systemctl enable docker + description: Enable Docker on boot + requires_root: true + rollback: systemctl disable docker + - command: curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + description: Download kubectl + requires_root: false + - command: install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl + description: Install kubectl + requires_root: true + - command: rm kubectl + description: Clean up kubectl download + requires_root: false + - command: apt install -y ansible terraform git + description: Install Ansible, Terraform, and Git + requires_root: true + +hardware_requirements: + min_ram_mb: 4096 + min_cores: 4 + min_storage_mb: 20480 + +post_install: + - echo "DevOps stack installed successfully" + - echo "Docker version: $(docker --version)" + - echo "Kubernetes: $(kubectl version --client --short 2>/dev/null || echo 'installed')" + - echo "Terraform: $(terraform --version | head -n1)" + - echo "Ansible: $(ansible --version | head -n1)" + +verification_commands: + - docker --version + - docker ps + - kubectl version --client + - terraform --version + - ansible --version + - git --version + - systemctl is-active docker + +metadata: + category: devops + tags: + - docker + - kubernetes + - terraform + - ansible + - ci-cd + - infrastructure + diff --git a/cortex/templates/lamp.yaml b/cortex/templates/lamp.yaml new file mode 100644 index 00000000..3e011e55 --- /dev/null +++ b/cortex/templates/lamp.yaml @@ -0,0 +1,77 @@ +name: LAMP Stack +description: Linux, Apache, MySQL, PHP stack for web development +version: 1.0.0 +author: Cortex Linux +packages: + - apache2 + - mysql-server + - mysql-client + - php + - php-mysql + - php-mbstring + - php-xml + - php-curl + - phpmyadmin + - libapache2-mod-php + +steps: + - command: apt update + description: Update package lists + requires_root: true + - command: echo "mysql-server mysql-server/root_password password temp_password" | debconf-set-selections && echo "mysql-server mysql-server/root_password_again password temp_password" | debconf-set-selections + description: Pre-configure MySQL root password + requires_root: true + - command: DEBIAN_FRONTEND=noninteractive apt install -y apache2 mysql-server mysql-client php php-mysql php-mbstring php-xml php-curl libapache2-mod-php + description: Install LAMP stack packages + requires_root: true + - command: echo "phpmyadmin phpmyadmin/dbconfig-install boolean true" | debconf-set-selections && echo "phpmyadmin phpmyadmin/reconfigure-webserver multiselect apache2" | debconf-set-selections && DEBIAN_FRONTEND=noninteractive apt install -y phpmyadmin + description: Install and configure phpMyAdmin for Apache + requires_root: true + - command: ln -sf /usr/share/phpmyadmin /var/www/html/phpmyadmin + description: Create phpMyAdmin symlink + requires_root: true + - command: systemctl start apache2 + description: Start Apache web server + requires_root: true + rollback: systemctl stop apache2 + - command: systemctl enable apache2 + description: Enable Apache on boot + requires_root: true + rollback: systemctl disable apache2 + - command: systemctl start mysql + description: Start MySQL service + requires_root: true + rollback: systemctl stop mysql + - command: systemctl enable mysql + description: Enable MySQL on boot + requires_root: true + rollback: systemctl disable mysql + +hardware_requirements: + min_ram_mb: 1024 + min_cores: 2 + min_storage_mb: 5120 + +post_install: + - echo "LAMP stack installed successfully" + - echo "SECURITY: Run 'mysql_secure_installation' to secure MySQL" + - echo "SECURITY: Configure firewall rules for production use" + - echo "Apache: http://localhost" + - echo "phpMyAdmin: http://localhost/phpmyadmin" + - echo "SECURITY: Change default MySQL passwords before production use" + +verification_commands: + - apache2 -v + - mysql --version + - php -v + - systemctl is-active apache2 + - systemctl is-active mysql + +metadata: + category: web-development + tags: + - web + - server + - database + - php + diff --git a/cortex/templates/mean.yaml b/cortex/templates/mean.yaml new file mode 100644 index 00000000..0778e6fb --- /dev/null +++ b/cortex/templates/mean.yaml @@ -0,0 +1,73 @@ +name: MEAN Stack +description: MongoDB, Express.js, Angular, Node.js stack for modern web applications +version: 1.0.0 +author: Cortex Linux +packages: + - nodejs + - npm + - mongodb + - git + - curl + +steps: + - command: apt update + description: Update package lists + requires_root: true + - command: curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh && bash /tmp/nodesource_setup.sh && rm /tmp/nodesource_setup.sh + description: Download and add Node.js repository + requires_root: true + - command: apt install -y nodejs + description: Install Node.js + requires_root: true + - command: npm install -g @angular/cli express-generator + description: Install Angular CLI and Express generator globally + requires_root: true + - command: curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor + description: Import MongoDB GPG key + requires_root: true + - command: echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-8.0.list + description: Add MongoDB official apt repository + requires_root: true + - command: apt update + description: Update package lists + requires_root: true + - command: apt install -y mongodb-org + description: Install MongoDB from official repository + requires_root: true + - command: systemctl start mongod + description: Start MongoDB service + requires_root: true + rollback: systemctl stop mongod + - command: systemctl enable mongod + description: Enable MongoDB on boot + requires_root: true + rollback: systemctl disable mongod + +hardware_requirements: + min_ram_mb: 2048 + min_cores: 2 + min_storage_mb: 10240 + +post_install: + - echo "MEAN stack installed successfully" + - echo "Node.js version: $(node --version)" + - echo "npm version: $(npm --version)" + - echo "Angular CLI: $(ng version 2>/dev/null || echo 'installed')" + - echo "MongoDB: mongodb://localhost:27017" + +verification_commands: + - node --version + - npm --version + - mongosh --version || mongo --version + - systemctl is-active mongod + - ng version + +metadata: + category: web-development + tags: + - javascript + - nodejs + - mongodb + - angular + - express + diff --git a/cortex/templates/mern.yaml b/cortex/templates/mern.yaml new file mode 100644 index 00000000..59d84e8c --- /dev/null +++ b/cortex/templates/mern.yaml @@ -0,0 +1,73 @@ +name: MERN Stack +description: MongoDB, Express.js, React, Node.js stack for full-stack JavaScript development +version: 1.0.0 +author: Cortex Linux +packages: + - nodejs + - npm + - mongodb + - git + - curl + +steps: + - command: apt update + description: Update package lists + requires_root: true + - command: curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh && bash /tmp/nodesource_setup.sh && rm /tmp/nodesource_setup.sh + description: Download and add Node.js repository + requires_root: true + - command: apt install -y nodejs + description: Install Node.js + requires_root: true + - command: npm install -g create-react-app express-generator + description: Install React and Express generators globally + requires_root: true + - command: curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor + description: Import MongoDB GPG key + requires_root: true + - command: echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-8.0.list + description: Add MongoDB official apt repository + requires_root: true + - command: apt update + description: Update package lists + requires_root: true + - command: apt install -y mongodb-org + description: Install MongoDB from official repository + requires_root: true + - command: systemctl start mongod + description: Start MongoDB service + requires_root: true + rollback: systemctl stop mongod + - command: systemctl enable mongod + description: Enable MongoDB on boot + requires_root: true + rollback: systemctl disable mongod + +hardware_requirements: + min_ram_mb: 2048 + min_cores: 2 + min_storage_mb: 10240 + +post_install: + - echo "MERN stack installed successfully" + - echo "Node.js version: $(node --version)" + - echo "npm version: $(npm --version)" + - echo "React: Create apps with 'npx create-react-app'" + - echo "MongoDB: mongodb://localhost:27017" + +verification_commands: + - node --version + - npm --version + - mongosh --version || mongo --version + - systemctl is-active mongod + - npx create-react-app --version + +metadata: + category: web-development + tags: + - javascript + - nodejs + - mongodb + - react + - express + diff --git a/cortex/templates/ml-ai.yaml b/cortex/templates/ml-ai.yaml new file mode 100644 index 00000000..681385a0 --- /dev/null +++ b/cortex/templates/ml-ai.yaml @@ -0,0 +1,66 @@ +name: ML/AI Stack +description: Machine Learning and Artificial Intelligence development stack with Python, TensorFlow, PyTorch, and Jupyter. GPU recommended for deep learning workloads. +version: 1.0.0 +author: Cortex Linux +packages: + - python3 + - python3-pip + - python3-venv + - python3-dev + - build-essential + - git + - curl + - wget + +steps: + - command: apt update + description: Update package lists + requires_root: true + - command: apt install -y python3 python3-pip python3-venv python3-dev build-essential git curl wget + description: Install Python and build tools + requires_root: true + - command: pip3 install --upgrade pip + description: Upgrade pip to latest version + requires_root: false + - command: pip3 install numpy pandas scipy matplotlib scikit-learn jupyter notebook + description: Install core ML libraries + requires_root: false + - command: pip3 install tensorflow torch torchvision torchaudio + description: Install deep learning frameworks + requires_root: false + - command: pip3 install seaborn plotly opencv-python-headless + description: Install additional ML/visualization libraries + requires_root: false + +hardware_requirements: + min_ram_mb: 8192 + min_cores: 4 + min_storage_mb: 30720 + requires_gpu: false + requires_cuda: false + +post_install: + - echo "ML/AI stack installed successfully" + - echo "Python version: $(python3 --version)" + - echo "Start Jupyter: jupyter notebook" + - echo "TensorFlow: $(python3 -c 'import tensorflow as tf; print(tf.__version__)' 2>/dev/null || echo 'installed')" + - echo "PyTorch: $(python3 -c 'import torch; print(torch.__version__)' 2>/dev/null || echo 'installed')" + +verification_commands: + - python3 --version + - pip3 --version + - python3 -c "import numpy; print('NumPy:', numpy.__version__)" + - python3 -c "import pandas; print('Pandas:', pandas.__version__)" + - python3 -c "import sklearn; print('Scikit-learn:', sklearn.__version__)" + - jupyter --version + +metadata: + category: machine-learning + tags: + - python + - machine-learning + - ai + - tensorflow + - pytorch + - jupyter + diff --git a/docs/STACKS.md b/docs/STACKS.md new file mode 100644 index 00000000..085587e0 --- /dev/null +++ b/docs/STACKS.md @@ -0,0 +1,618 @@ +# Installation Stacks Guide + +Cortex Linux provides a powerful stack system for installing common development environments and software bundles. Stacks are pre-configured installation definitions that can be shared, customized, and reused. + +## Overview + +Stacks allow you to: +- Install complete development environments with a single command +- Share installation configurations with your team +- Create custom stacks for your specific needs +- Validate hardware compatibility before installation +- Export and import stacks for easy sharing + +## Quick Start + +### Installing from a Stack + +```bash +# List available stacks +cortex stack list + +# Install LAMP stack (preview first) +cortex install --stack lamp --dry-run + +# Install LAMP stack +cortex install --stack lamp --execute + +# Install MEAN stack +cortex install --stack mean --execute +``` + +### Creating a Custom Stack + +```bash +# Create a new stack interactively +cortex stack create my-stack + +# Import a stack from file +cortex stack import my-stack.yaml + +# Export a stack +cortex stack export lamp my-lamp-stack.yaml +``` + +## Built-in Stacks + +Cortex Linux comes with 5+ pre-built stacks: + +### 1. LAMP Stack + +Linux, Apache, MySQL, PHP stack for traditional web development. + +```bash +cortex install --stack lamp --execute +``` + +**Packages:** +- Apache 2.4 +- MySQL 8.0 +- PHP 8.2 +- phpMyAdmin + +**Hardware Requirements:** +- Minimum RAM: 1GB +- Minimum CPU cores: 2 +- Minimum storage: 5GB + +**Access:** +- Apache: http://localhost +- phpMyAdmin: http://localhost/phpmyadmin + +### 2. MEAN Stack + +MongoDB, Express.js, Angular, Node.js stack for modern web applications. + +```bash +cortex install --stack mean --execute +``` + +**Packages:** +- Node.js 20.x +- MongoDB +- Angular CLI +- Express generator + +**Hardware Requirements:** +- Minimum RAM: 2GB +- Minimum CPU cores: 2 +- Minimum storage: 10GB + +### 3. MERN Stack + +MongoDB, Express.js, React, Node.js stack for full-stack JavaScript development. + +```bash +cortex install --stack mern --execute +``` + +**Packages:** +- Node.js 20.x +- MongoDB +- React (via create-react-app) +- Express generator + +**Hardware Requirements:** +- Minimum RAM: 2GB +- Minimum CPU cores: 2 +- Minimum storage: 10GB + +### 4. ML/AI Stack + +Machine Learning and Artificial Intelligence development stack. + +```bash +cortex install --stack ml-ai --execute +``` + +**Packages:** +- Python 3.x +- NumPy, Pandas, SciPy +- TensorFlow +- PyTorch +- Jupyter Notebook +- Scikit-learn +- Matplotlib, Seaborn + +**Hardware Requirements:** +- Minimum RAM: 4GB +- Minimum CPU cores: 4 +- Minimum storage: 20GB + +### 5. DevOps Stack + +Complete DevOps toolchain with containerization and infrastructure tools. + +```bash +cortex install --stack devops --execute +``` + +**Packages:** +- Docker & Docker Compose +- Kubernetes (kubectl) +- Terraform +- Ansible +- Git +- Jenkins (optional) + +**Hardware Requirements:** +- Minimum RAM: 4GB +- Minimum CPU cores: 4 +- Minimum storage: 20GB + +## Stack Format + +Stacks are defined in YAML or JSON format. Here's the structure: + +### YAML Format + +```yaml +name: My Custom Stack +description: A custom development stack +version: 1.0.0 +author: Your Name + +packages: + - package1 + - package2 + - package3 + +steps: + - command: apt update + description: Update package lists + requires_root: true + - command: apt install -y package1 package2 + description: Install packages + requires_root: true + rollback: apt remove -y package1 package2 + +hardware_requirements: + min_ram_mb: 2048 + min_cores: 2 + min_storage_mb: 10240 + requires_gpu: false + requires_cuda: false + +post_install: + - echo "Stack installed successfully" + - echo "Access at: http://localhost" + +verification_commands: + - package1 --version + - systemctl is-active service1 + +metadata: + category: web-development + tags: + - web + - server +``` + +### JSON Format + +```json +{ + "name": "My Custom Stack", + "description": "A custom development stack", + "version": "1.0.0", + "author": "Your Name", + "packages": [ + "package1", + "package2" + ], + "steps": [ + { + "command": "apt update", + "description": "Update package lists", + "requires_root": true + } + ], + "hardware_requirements": { + "min_ram_mb": 2048, + "min_cores": 2, + "min_storage_mb": 10240 + }, + "post_install": [ + "echo 'Stack installed successfully'" + ], + "verification_commands": [ + "package1 --version" + ] +} +``` + +## Stack Fields + +### Required Fields + +- **name**: Stack name (string) +- **description**: Stack description (string) +- **version**: Stack version (string, e.g., "1.0.0") + +### Optional Fields + +- **author**: Stack author (string) +- **packages**: List of package names (array of strings) +- **steps**: Installation steps (array of step objects) +- **hardware_requirements**: Hardware requirements (object) +- **post_install**: Post-installation commands (array of strings) +- **verification_commands**: Commands to verify installation (array of strings) +- **metadata**: Additional metadata (object) + +### Installation Steps + +Each step can have: +- **command**: Command to execute (required) +- **description**: Step description (required) +- **rollback**: Rollback command (optional) +- **verify**: Verification command (optional) +- **requires_root**: Whether root is required (boolean, default: true) + +### Hardware Requirements + +- **min_ram_mb**: Minimum RAM in megabytes (integer) +- **min_cores**: Minimum CPU cores (integer) +- **min_storage_mb**: Minimum storage in megabytes (integer) +- **requires_gpu**: Whether GPU is required (boolean) +- **gpu_vendor**: Required GPU vendor ("NVIDIA", "AMD", "Intel") +- **requires_cuda**: Whether CUDA is required (boolean) +- **min_cuda_version**: Minimum CUDA version (string, e.g., "11.0") + +## Creating Custom Stacks + +### Interactive Creation + +```bash +cortex stack create my-stack +``` + +This will prompt you for: +- Description +- Version +- Author (optional) +- Packages (one per line) +- Hardware requirements (optional) + +### Manual Creation + +1. Create a YAML or JSON file: + +```yaml +name: my-custom-stack +description: My custom development stack +version: 1.0.0 +packages: + - python3 + - nodejs + - docker +``` + +2. Import the stack: + +```bash +cortex stack import my-stack.yaml +``` + +3. Use the stack: + +```bash +cortex install --stack my-custom-stack --execute +``` + +## Stack Management + +### Listing Stacks + +```bash +cortex stack list +``` + +Output: +``` +šŸ“‹ Available Stacks: +================================================================================ +Name Version Type Description +================================================================================ +devops 1.0.0 built-in Complete DevOps toolchain... +lamp 1.0.0 built-in Linux, Apache, MySQL, PHP... +mean 1.0.0 built-in MongoDB, Express.js, Angular... +mern 1.0.0 built-in MongoDB, Express.js, React... +ml-ai 1.0.0 built-in Machine Learning and AI... + +Total: 5 stacks + +To install a stack: + cortex install --stack --dry-run # Preview + cortex install --stack --execute # Install +``` + +### Describing Stacks + +```bash +cortex stack describe lamp +``` + +Output: +``` +šŸ“¦ Stack: LAMP Stack + Linux, Apache, MySQL, PHP development stack + Version: 1.0.0 + + Packages: + - apache2 + - mysql-server + - php + - phpmyadmin + + Hardware Requirements: + - Minimum RAM: 1024MB + - Minimum CPU cores: 2 + - Minimum storage: 5120MB + + To install this stack: + cortex install --stack lamp --dry-run # Preview + cortex install --stack lamp --execute # Install +``` + +### Exporting Stacks + +```bash +# Export to YAML (default) +cortex stack export lamp my-lamp-stack.yaml + +# Export to JSON +cortex stack export lamp my-lamp-stack.json --format json +``` + +### Importing Stacks + +```bash +# Import with original name +cortex stack import my-stack.yaml + +# Import with custom name +cortex stack import my-stack.yaml --name my-custom-name +``` + +## Hardware Compatibility + +Stacks can specify hardware requirements. Cortex will check compatibility before installation: + +```bash +$ cortex install --stack ml-ai --execute + +šŸ“¦ ML/AI Stack: + Machine Learning and Artificial Intelligence development stack + + Packages: + - python3 + - python3-pip + ... + +āš ļø Hardware Compatibility Warnings: + - Insufficient RAM: 2048MB available, 4096MB required + +āš ļø Hardware requirements not met. Continue anyway? (y/N): +``` + +## Stack Validation + +Stacks are automatically validated before installation. Validation checks: + +- Required fields are present +- At least packages or steps are defined +- Step commands and descriptions are provided +- Hardware requirements are valid (non-negative values) +- CUDA requirements are consistent with GPU requirements + +## Example Stacks + +### Python Data Science Stack + +```yaml +name: Python Data Science +description: Python with data science libraries +version: 1.0.0 +packages: + - python3 + - python3-pip + - python3-venv +steps: + - command: pip3 install numpy pandas scipy matplotlib jupyter scikit-learn + description: Install data science libraries + requires_root: false +hardware_requirements: + min_ram_mb: 2048 + min_cores: 2 +``` + +### Docker Development Stack + +```yaml +name: Docker Development +description: Docker with development tools +version: 1.0.0 +packages: + - docker.io + - docker-compose + - git +steps: + - command: systemctl start docker + description: Start Docker service + requires_root: true + rollback: systemctl stop docker + - command: systemctl enable docker + description: Enable Docker on boot + requires_root: true +verification_commands: + - docker --version + - docker ps +``` + +### Full-Stack Web Development + +```yaml +name: Full-Stack Web +description: Complete web development environment +version: 1.0.0 +packages: + - nodejs + - npm + - python3 + - postgresql + - redis-server + - nginx +steps: + - command: npm install -g yarn typescript + description: Install global Node.js tools + requires_root: false + - command: systemctl start postgresql + description: Start PostgreSQL + requires_root: true + - command: systemctl start redis + description: Start Redis + requires_root: true +hardware_requirements: + min_ram_mb: 4096 + min_cores: 4 +post_install: + - echo "Web development stack ready!" + - echo "PostgreSQL: localhost:5432" + - echo "Redis: localhost:6379" +``` + +## Best Practices + +1. **Always test stacks in dry-run mode first:** + ```bash + cortex install --stack my-stack --dry-run + ``` + +2. **Specify hardware requirements** to help users understand system needs + +3. **Include verification commands** to ensure installation succeeded + +4. **Add rollback commands** for critical steps to enable safe rollback + +5. **Use descriptive step descriptions** for better user experience + +6. **Version your stacks** to track changes + +7. **Document post-installation steps** in post_install commands + +## Troubleshooting + +### Stack Not Found + +If a stack is not found, check: +- Stack name is correct (use `cortex stack list` to verify) +- Stack file exists in `~/.cortex/templates/` or built-in stacks directory +- File extension is `.yaml`, `.yml`, or `.json` + +### Validation Errors + +If stack validation fails: +- Check all required fields are present +- Ensure at least packages or steps are defined +- Verify hardware requirements are non-negative +- Check step commands and descriptions are provided + +### Hardware Compatibility Warnings + +If hardware compatibility warnings appear: +- Review the warnings carefully +- Consider if the installation will work with your hardware +- Some stacks may work with less hardware but with reduced performance +- You can proceed anyway if you understand the risks + +## Stack Sharing + +Stacks can be shared by: +1. Exporting to a file +2. Sharing the file via version control, email, or file sharing +3. Importing on another system + +Example workflow: +```bash +# On source system +cortex stack export my-stack my-stack.yaml + +# Share my-stack.yaml + +# On target system +cortex stack import my-stack.yaml +cortex install --stack my-stack --execute +``` + +## Advanced Usage + +### Using Steps Instead of Packages + +For more control, use explicit installation steps: + +```yaml +steps: + - command: apt update + description: Update package lists + - command: curl -fsSL https://get.docker.com | sh + description: Install Docker + rollback: apt remove -y docker docker-engine + - command: systemctl start docker + description: Start Docker service + verify: systemctl is-active docker +``` + +### Conditional Installation + +While stacks don't support conditional logic directly, you can use shell commands: + +```yaml +steps: + - command: | + if [ ! -f /usr/bin/docker ]; then + apt install -y docker.io + fi + description: Install Docker if not present +``` + +### Post-Installation Configuration + +Use post_install commands for configuration: + +```yaml +post_install: + - echo "Configuring service..." + - systemctl enable myservice + - echo "Service configured. Access at http://localhost:8080" +``` + +## Migration from Templates + +If you were using the older `cortex template` commands, they have been renamed to `cortex stack`: + +| Old Command | New Command | +|------------|-------------| +| `cortex template list` | `cortex stack list` | +| `cortex template create ` | `cortex stack create ` | +| `cortex template import ` | `cortex stack import ` | +| `cortex template export ` | `cortex stack export ` | +| `cortex install --template ` | `cortex install --stack ` | + +The old `cortex template` commands still work but will show a deprecation warning. + +## See Also + +- [User Guide](../User-Guide.md) - General Cortex usage +- [Developer Guide](../Developer-Guide.md) - Contributing to Cortex +- [Getting Started](../Getting-Started.md) - Quick start guide + diff --git a/requirements.txt b/requirements.txt index 166a777e..60b43529 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ requests>=2.32.4 # Configuration PyYAML>=6.0.0 +# YAML parsing for template system +PyYAML>=6.0 # Environment variable loading from .env files python-dotenv>=1.0.0 diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 00000000..0cad4769 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +""" +Unit tests for Cortex Linux Template System +""" + +import json +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from cortex.templates import ( + HardwareRequirements, + InstallationStep, + Template, + TemplateFormat, + TemplateManager, + TemplateValidator, +) + + +class TestTemplate(unittest.TestCase): + """Test Template dataclass.""" + + def test_template_creation(self): + """Test creating a template.""" + template = Template( + name="test-template", + description="Test template", + version="1.0.0", + author="Test Author", + packages=["package1", "package2"], + ) + + self.assertEqual(template.name, "test-template") + self.assertEqual(template.description, "Test template") + self.assertEqual(template.version, "1.0.0") + self.assertEqual(template.author, "Test Author") + self.assertEqual(len(template.packages), 2) + + def test_template_to_dict(self): + """Test converting template to dictionary.""" + template = Template( + name="test", description="Test", version="1.0.0", packages=["pkg1", "pkg2"] + ) + + data = template.to_dict() + + self.assertEqual(data["name"], "test") + self.assertEqual(data["description"], "Test") + self.assertEqual(data["version"], "1.0.0") + self.assertEqual(data["packages"], ["pkg1", "pkg2"]) + + def test_template_from_dict(self): + """Test creating template from dictionary.""" + data = { + "name": "test", + "description": "Test description", + "version": "1.0.0", + "packages": ["pkg1", "pkg2"], + "steps": [ + { + "command": "apt install pkg1", + "description": "Install package 1", + "requires_root": True, + } + ], + } + + template = Template.from_dict(data) + + self.assertEqual(template.name, "test") + self.assertEqual(template.description, "Test description") + self.assertEqual(len(template.packages), 2) + self.assertEqual(len(template.steps), 1) + self.assertEqual(template.steps[0].command, "apt install pkg1") + + def test_template_with_hardware_requirements(self): + """Test template with hardware requirements.""" + hw_req = HardwareRequirements( + min_ram_mb=4096, min_cores=4, requires_gpu=True, gpu_vendor="NVIDIA" + ) + + template = Template( + name="gpu-template", + description="GPU template", + version="1.0.0", + hardware_requirements=hw_req, + ) + + self.assertIsNotNone(template.hardware_requirements) + self.assertEqual(template.hardware_requirements.min_ram_mb, 4096) + self.assertEqual(template.hardware_requirements.requires_gpu, True) + + +class TestTemplateValidator(unittest.TestCase): + """Test TemplateValidator.""" + + def test_validate_valid_template(self): + """Test validating a valid template.""" + template = Template( + name="valid-template", description="Valid template", version="1.0.0", packages=["pkg1"] + ) + + is_valid, errors = TemplateValidator.validate(template) + + self.assertTrue(is_valid) + self.assertEqual(len(errors), 0) + + def test_validate_missing_name(self): + """Test validating template with missing name.""" + template = Template(name="", description="Test", version="1.0.0") + + is_valid, errors = TemplateValidator.validate(template) + + self.assertFalse(is_valid) + self.assertIn("name is required", errors[0]) + + def test_validate_missing_packages_and_steps(self): + """Test validating template with no packages or steps.""" + template = Template(name="empty-template", description="Empty template", version="1.0.0") + + is_valid, errors = TemplateValidator.validate(template) + + self.assertFalse(is_valid) + self.assertIn("packages or steps", errors[0]) + + def test_validate_invalid_hardware_requirements(self): + """Test validating template with invalid hardware requirements.""" + hw_req = HardwareRequirements(min_ram_mb=-1) + template = Template( + name="invalid-hw", + description="Invalid hardware", + version="1.0.0", + packages=["pkg1"], + hardware_requirements=hw_req, + ) + + is_valid, errors = TemplateValidator.validate(template) + + self.assertFalse(is_valid) + self.assertIn("min_ram_mb", errors[0]) + + +class TestTemplateManager(unittest.TestCase): + """Test TemplateManager.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + self.template_dir = Path(self.temp_dir) / "templates" + self.template_dir.mkdir() + + # Create a test template + self.test_template_data = { + "name": "test-template", + "description": "Test template", + "version": "1.0.0", + "packages": ["package1", "package2"], + "steps": [ + {"command": "apt update", "description": "Update packages", "requires_root": True} + ], + } + + # Write test template to file + template_file = self.template_dir / "test-template.yaml" + with open(template_file, "w") as f: + yaml.dump(self.test_template_data, f) + + def tearDown(self): + """Clean up test fixtures.""" + shutil.rmtree(self.temp_dir) + + def test_load_template(self): + """Test loading a template.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + template = manager.load_template("test-template") + + self.assertIsNotNone(template) + self.assertEqual(template.name, "test-template") + self.assertEqual(len(template.packages), 2) + + def test_load_nonexistent_template(self): + """Test loading a non-existent template.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + template = manager.load_template("nonexistent") + + self.assertIsNone(template) + + def test_save_template(self): + """Test saving a template.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + template = Template( + name="new-template", description="New template", version="1.0.0", packages=["pkg1"] + ) + + template_path = manager.save_template(template, "new-template") + + self.assertTrue(template_path.exists()) + + # Verify it can be loaded + loaded = manager.load_template("new-template") + self.assertIsNotNone(loaded) + self.assertEqual(loaded.name, "new-template") + + def test_list_templates(self): + """Test listing templates.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + templates = manager.list_templates() + + self.assertIn("test-template", templates) + self.assertEqual(templates["test-template"]["name"], "test-template") + + def test_generate_commands_from_packages(self): + """Test generating commands from packages.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + template = Template( + name="test", description="Test", version="1.0.0", packages=["package1", "package2"] + ) + + commands = manager.generate_commands(template) + + self.assertGreater(len(commands), 0) + self.assertTrue(any("package1" in cmd or "package2" in cmd for cmd in commands)) + + def test_generate_commands_from_steps(self): + """Test generating commands from steps.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + template = Template( + name="test", + description="Test", + version="1.0.0", + steps=[InstallationStep(command="apt install pkg1", description="Install pkg1")], + ) + + commands = manager.generate_commands(template) + + self.assertEqual(len(commands), 1) + self.assertEqual(commands[0], "apt install pkg1") + + def test_import_template(self): + """Test importing a template from file.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + # Create a temporary template file + temp_file = Path(self.temp_dir) / "import-template.yaml" + with open(temp_file, "w") as f: + yaml.dump(self.test_template_data, f) + + template = manager.import_template(str(temp_file)) + + self.assertIsNotNone(template) + self.assertEqual(template.name, "test-template") + + def test_export_template(self): + """Test exporting a template to file.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + export_path = Path(self.temp_dir) / "exported-template.yaml" + manager.export_template("test-template", str(export_path)) + + self.assertTrue(export_path.exists()) + + # Verify content + with open(export_path) as f: + data = yaml.safe_load(f) + self.assertEqual(data["name"], "test-template") + + +class TestHardwareCompatibility(unittest.TestCase): + """Test hardware compatibility checking.""" + + def setUp(self): + """Set up test fixtures.""" + self.manager = TemplateManager() + + def test_check_hardware_compatibility_no_requirements(self): + """Test checking compatibility with no requirements.""" + template = Template(name="test", description="Test", version="1.0.0", packages=["pkg1"]) + + is_compatible, warnings = self.manager.check_hardware_compatibility(template) + + self.assertTrue(is_compatible) + self.assertEqual(len(warnings), 0) + + def test_check_hardware_compatibility_with_requirements(self): + """Test checking compatibility with requirements.""" + hw_req = HardwareRequirements(min_ram_mb=1024, min_cores=2) + + template = Template( + name="test", + description="Test", + version="1.0.0", + packages=["pkg1"], + hardware_requirements=hw_req, + ) + + is_compatible, warnings = self.manager.check_hardware_compatibility(template) + + # Result depends on actual hardware, but should not crash + self.assertIsInstance(is_compatible, bool) + self.assertIsInstance(warnings, list) + + +class TestTemplateFormat(unittest.TestCase): + """Test template format handling.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + self.template_dir = Path(self.temp_dir) / "templates" + self.template_dir.mkdir() + + def tearDown(self): + """Clean up test fixtures.""" + shutil.rmtree(self.temp_dir) + + def test_save_yaml_format(self): + """Test saving template in YAML format.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + template = Template( + name="yaml-test", description="YAML test", version="1.0.0", packages=["pkg1"] + ) + + template_path = manager.save_template(template, "yaml-test", TemplateFormat.YAML) + + self.assertTrue(template_path.exists()) + self.assertEqual(template_path.suffix, ".yaml") + + # Verify it's valid YAML + with open(template_path) as f: + data = yaml.safe_load(f) + self.assertEqual(data["name"], "yaml-test") + + def test_save_json_format(self): + """Test saving template in JSON format.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + template = Template( + name="json-test", description="JSON test", version="1.0.0", packages=["pkg1"] + ) + + template_path = manager.save_template(template, "json-test", TemplateFormat.JSON) + + self.assertTrue(template_path.exists()) + self.assertEqual(template_path.suffix, ".json") + + # Verify it's valid JSON + with open(template_path) as f: + data = json.load(f) + self.assertEqual(data["name"], "json-test") + + def test_load_yaml_template(self): + """Test loading YAML template.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + template_data = { + "name": "yaml-load", + "description": "YAML load test", + "version": "1.0.0", + "packages": ["pkg1"], + } + + template_file = self.template_dir / "yaml-load.yaml" + with open(template_file, "w") as f: + yaml.dump(template_data, f) + + template = manager.load_template("yaml-load") + + self.assertIsNotNone(template) + self.assertEqual(template.name, "yaml-load") + + def test_load_json_template(self): + """Test loading JSON template.""" + manager = TemplateManager(templates_dir=str(self.template_dir)) + + template_data = { + "name": "json-load", + "description": "JSON load test", + "version": "1.0.0", + "packages": ["pkg1"], + } + + template_file = self.template_dir / "json-load.json" + with open(template_file, "w") as f: + json.dump(template_data, f) + + template = manager.load_template("json-load") + + self.assertIsNotNone(template) + self.assertEqual(template.name, "json-load") + + +if __name__ == "__main__": + unittest.main()