From 3be9e4d1c8f453b1f338fb35b63aacbda87e9a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Fri, 2 Jan 2026 19:04:39 +0100 Subject: [PATCH 01/22] initial typer version --- README.md | 218 ++++++++-------- mantis/__init__.py | 2 +- mantis/command_line.py | 379 ++++++++++++++++------------ mantis/commands.py | 454 ++++++++++++++++++++++++++++++++++ mantis/extensions/django.py | 27 +- mantis/extensions/nginx.py | 8 + mantis/extensions/postgres.py | 72 ++++++ mantis/logic.py | 36 --- setup.py | 2 +- 9 files changed, 891 insertions(+), 307 deletions(-) create mode 100644 mantis/commands.py diff --git a/README.md b/README.md index b488934..7d7d54b 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ Use `connections` (dict) when you have multiple environments like stage, product In this mode, you must specify the environment in every command: ```bash -mantis production --status -mantis stage --deploy +mantis production status +mantis stage deploy ``` #### Single connection mode @@ -86,8 +86,8 @@ Use `connection` (string) when you only have one environment. This simplifies th In this mode, you can run commands without specifying an environment: ```bash -mantis --status -mantis --deploy +mantis status +mantis deploy ``` Environment files are looked up directly in the `environment.folder` instead of environment-specific subfolders. @@ -101,7 +101,7 @@ If you plan to use encryption and decryption of your environment files, you need Generation of new key: ```bash -mantis --generate-key +mantis generate-key ``` Save key to **mantis.key** file: @@ -110,24 +110,24 @@ Save key to **mantis.key** file: echo > /path/to/encryption/folder/mantis.key ``` -Then you can encrypt your environment files using symmetric encryption. +Then you can encrypt your environment files using symmetric encryption. Every environment variable is encrypted separately instead of encrypting the whole file for better tracking of changes in VCS. ```bash -mantis --encrypt-env +mantis encrypt-env ``` Decryption is easy like this: ```bash -mantis --decrypt-env +mantis decrypt-env ``` -When decrypting, mantis prompts user for confirmation. +When decrypting, mantis prompts user for confirmation. You can bypass that by forcing decryption which can be useful in CI/CD pipeline: ```bash -mantis --decrypt-env:force +mantis decrypt-env --force ``` ## Usage @@ -135,28 +135,33 @@ mantis --decrypt-env:force General usage of mantis-cli has this format: ```bash -mantis [--mode=remote|ssh|host] [environment] --command[:params] +mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... ``` -### Modes +### Options -Mantis can operate in 3 different modes depending on a way it connects to remote machhine +| Option | Description | +|-----------------|---------------------------------------------------| +| --mode, -m | Execution mode: remote (default), ssh, host | +| --version, -v | Show version and exit | +| --help, -h | Show help message | +### Modes -#### Remote mode ```--mode=remote``` +Mantis can operate in 3 different modes depending on how it connects to remote machine: -Runs commands remotely from local machine using DOCKER_HOST or DOCKER_CONTEXT (default) +#### Remote mode `--mode=remote` -#### SSH mode ```--mode=ssh``` +Runs commands remotely from local machine using DOCKER_HOST or DOCKER_CONTEXT (default) -Connects to host via ssh and run all mantis commands on remote machine directly (nantis-cli needs to be installed on server) +#### SSH mode `--mode=ssh` +Connects to host via SSH and runs all mantis commands on remote machine directly (mantis-cli needs to be installed on server) -#### Host mode ```--mode=host``` +#### Host mode `--mode=host` Runs mantis on host machine directly without invoking connection (used as proxy for ssh mode) - ### Environments Environment can be either *local* or any custom environment like *stage*, *production* etc. @@ -164,79 +169,84 @@ The environment is also used as an identifier for remote connection. ### Commands -| Command / Shortcut | Description | -|----------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------| -| --bash:params | Runs bash in container | -| --build[:params] / -b | Builds all services with Dockerfiles | -| --check-config | Validates config file according to template | -| --check-env | Compares encrypted and decrypted env files | -| --check-health:container | Checks current health of given container | -| --clean[:params] / -c | Clean images, containers, networks | -| --contexts | Prints all docker contexts | -| --create-context | Creates docker context using user inputs | -| --decrypt-env[:params,env_file,return_value] | Decrypts all environment files (force param skips user confirmation) | -| --deploy[:dirty] / -d | Runs deployment process: uploads files, pulls images, runs zero-downtime deployment, removes suffixes, reloads webserver, clean | -| --down[:params] | Calls compose down (with optional params) | -| --encrypt-env[:params,env_file,return_value] | Encrypts all environment files (force param skips user confirmation) | -| --exec:params | Executes command in container | -| --generate-key | Creates new encryption key | -| --get-container-name:service | Constructs container name with project prefix for given service | -| --get-container-suffix:service | Returns the suffix used for containers for given service | -| --get-deploy-replicas:service | Returns default number of deploy replicas of given services | -| --get-healthcheck-config:container | Prints health-check config (if any) of given container | -| --get-healthcheck-start-period:container | Returns healthcheck start period for given container (if any) | -| --get-image-name:service | Constructs image name for given service | -| --get-image-suffix:service | Returns the suffix used for image for given service | -| --get-number-of-containers:service | Prints number of containers for given service | -| --get-service-containers:service | Prints container names of given service | -| --has-healthcheck:container | Checks if given container has defined healthcheck | -| --healthcheck[:container] / -hc | Execute health-check of given project container | -| --kill[:params] | Kills all or given project container | -| --logs[:params] / -l | Prints logs of all or given project container | -| --manage:params | Runs Django manage command | -| --networks / -n | Prints docker networks | -| --pg-dump[:data_only,table] | Backups PostgreSQL database [data and structure] | -| --pg-dump-data[:table] | Backups PostgreSQL database [data only] | -| --pg-restore[:filename,table] | Restores database from backup [data and structure] | -| --pg-restore-data:params | Restores database from backup [data only] | -| --psql | Starts psql console | -| --pull[:params] / -p | Pulls required images for services | -| --push[:params] | Push built images to repository | -| --read-key | Returns value of mantis encryption key | -| --remove[:params] | Removes all or given project container | -| --remove-suffixes[:prefix] | Removes numerical suffixes from container names (if scale == 1) | -| --restart[:service] | Restarts all containers by calling compose down and up | -| --restart-service:service | Stops, removes and recreates container for given service | -| --run:params | Calls compose run with params | -| --scale:service,scale | Scales service to given scale | -| --send-test-email | Sends test email to admins using Django 'sendtestemail' command | -| --services | Prints all defined services | -| --services-to-build | Prints all services which will be build | -| --sh:params | Runs sh in container | -| --shell | Runs and connects to Django shell | -| --start[:params] | Starts all or given project container | -| --status / -s | Prints images and containers | -| --stop[:params] | Stops all or given project container | -| --try-to-reload-webserver | Tries to reload webserver (if suitable extension is available) | -| --up[:params] | Calls compose up (with optional params) | -| --upload / -u | Uploads mantis config, compose file
and environment files to server | -| --zero-downtime[:service] | Runs zero-downtime deployment of services (or given service) | -| --backup-volume:volume | Backups volume to a file | -| --restore-volume:volume,file | Restores volume from a file | - -Few examples: +Run `mantis commands` to see all available commands with their descriptions. + +| Command / Shortcut | Description | +|-------------------------------|-------------------------------------------------------------------| +| status / s | Prints images and containers | +| deploy [--dirty] / d | Runs deployment process | +| build [services...] / b | Builds all services with Dockerfiles | +| pull [services...] / p | Pulls required images for services | +| push [services...] / u | Push built images to repository | +| upload | Uploads config, compose and env files to server | +| clean / c | Clean images, containers, networks | +| logs [container] / l | Prints logs of containers | +| networks / n | Prints docker networks | +| healthcheck [container] / hc | Execute health-check of container | +| up [params...] | Calls compose up | +| down [params...] | Calls compose down | +| restart [service] | Restarts all containers | +| stop [containers...] | Stops containers | +| start [containers...] | Starts containers | +| kill [containers...] | Kills containers | +| remove [containers...] | Removes containers | +| bash | Runs bash in container | +| sh | Runs sh in container | +| exec | Executes command in container | +| ssh | Connects to remote host via SSH | +| scale | Scales service to given number | +| zero-downtime [service] | Runs zero-downtime deployment | +| encrypt-env [--force] | Encrypts environment files | +| decrypt-env [--force] | Decrypts environment files | +| check-env | Compares encrypted and decrypted env files | +| generate-key | Creates new encryption key | +| read-key | Returns encryption key value | +| check-config | Validates config file | +| contexts | Prints all docker contexts | +| create-context | Creates docker context | +| services | Lists all defined services | +| commands | Lists all available commands | + +**Django extension:** + +| Command | Description | +|-------------------------------|-------------------------------------------------------------------| +| shell | Runs Django shell | +| manage [args...] | Runs Django manage command | +| send-test-email | Sends test email to admins | + +**PostgreSQL extension:** + +| Command | Description | +|-------------------------------|-------------------------------------------------------------------| +| psql | Starts psql console | +| pg-dump [--data-only] [--table TABLE] | Backups database | +| pg-restore [--table TABLE] | Restores database from backup | + +**Nginx extension:** + +| Command | Description | +|-------------------------------|-------------------------------------------------------------------| +| reload-webserver | Reloads nginx | + +### Examples ```bash mantis --version -mantis local --encrypt-env -mantis stage --build -mantis production --logs:container-name +mantis local encrypt-env +mantis stage build +mantis production logs web + +# Run multiple commands at once +mantis stage build push deploy status logs -# you can also run multiple commands at once -mantis stage --build --push --deploy -s -l +# Commands with arguments +mantis production deploy --dirty +mantis production manage migrate +mantis production pg-dump --data-only --table users ``` -Check ``mantis --help`` for more details. +Check `mantis --help` for more details, or `mantis commands` for a full list of commands. ## Flow @@ -245,10 +255,10 @@ Check ``mantis --help`` for more details. Once you define mantis config for your project and optionally create encryption key, you can build your docker images: ```bash -mantis --build +mantis build ``` -Mantis either uses ```docker-compose --build``` or ```docker build``` command depending on build tool defined in your config. +Mantis either uses `docker-compose --build` or `docker build` command depending on build tool defined in your config. Build image names use '_' as word separator. ### 2. Push @@ -256,7 +266,7 @@ Build image names use '_' as word separator. Built images needs to be pushed to your repository defined in compose file (you need to authenticate) ```bash -mantis --push +mantis push ``` ### 3. Deployment @@ -264,7 +274,7 @@ mantis --push Deployment to your remote server is being executed by calling simple command: ```bash -mantis --deploy +mantis deploy ``` The deployment process consists of multiple steps: @@ -284,25 +294,25 @@ Docker container names use '-' as word separator (docker compose v2 convention). Once deployed, you can verify the container status: ```bash -mantis --status +mantis status ``` list all docker networks: ```bash -mantis --networks +mantis networks ``` and also check all container logs: ```bash -mantis --logs +mantis logs ``` If you need to follow logs of a specific container, you can do it by passing container name to command: ```bash -mantis --logs: +mantis logs ``` ### 5. Another useful commands @@ -310,21 +320,21 @@ mantis --logs: Sometimes, instead of calling whole deployment process, you just need to call compose commands directly: ```bash -mantis --up -mantis --down -mantis --restart -mantis --stop -mantis --kill -mantis --start -mantis --clean +mantis up +mantis down +mantis restart +mantis stop +mantis kill +mantis start +mantis clean ``` Commands over a single container: ```bash -mantis --bash:container-name -mantis --sh:container-name -mantis --run:params +mantis bash +mantis sh +mantis run ``` ## Zero-downtime deployment diff --git a/mantis/__init__.py b/mantis/__init__.py index f777239..de52e33 100644 --- a/mantis/__init__.py +++ b/mantis/__init__.py @@ -1 +1 @@ -VERSION = '20.0.0' +VERSION = '21.0.0' diff --git a/mantis/command_line.py b/mantis/command_line.py index b559c2f..3916849 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -1,215 +1,268 @@ #!/usr/bin/env python +""" +Mantis CLI - Docker deployment tool + +Usage: + mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... + +Examples: + mantis production status + mantis production deploy --dirty + mantis production build push deploy + mantis prod logs web + mantis manage migrate +""" import os import sys -import inspect +from typing import List, Optional +import typer from rich.console import Console -from rich.table import Table from rich.text import Text from mantis import VERSION -from mantis.helpers import CLI, nested_set -from mantis.logic import get_manager, execute -from mantis.managers import AbstractManager, BaseManager -from mantis.extensions.django import Django -from mantis.extensions.nginx import Nginx -from mantis.extensions.postgres import Postgres - - -def parse_args(arguments): - d = { - 'environment_id': None, - 'commands': [], - 'settings': {} - } - - for arg in arguments: - if not arg.startswith('-'): - d['environment_id'] = arg - # elif '=' in arg and ':' not in arg: - elif '=' in arg: - s, v = arg.split('=', maxsplit=1) - d['settings'][s.strip('-')] = v - else: - d['commands'].append(arg) +from mantis.commands import COMMANDS, NO_ENV_COMMANDS, get_command, list_commands +from mantis.helpers import CLI +from mantis.logic import get_manager - return d +app = typer.Typer( + add_completion=False, + invoke_without_command=True, + no_args_is_help=False, +) -def run(): - arguments = sys.argv.copy() - arguments.pop(0) +def print_version(): + """Print version information.""" + print(f"Mantis v{VERSION}") - # check params - params = parse_args(arguments) - # version info +def print_heading(manager, mode: str): + """Print the heading with environment and connection info.""" + console = Console() + hostname = os.popen('hostname').read().rstrip("\n") version_info = f'Mantis v{VERSION}' - if params['commands'] == ['--version']: - return print(version_info) + heading = Text() + heading.append(version_info) + heading.append(", ") - if params['commands'] == ['--help']: - return help() + if manager.environment_id: + heading.append("Environment ID = ") + heading.append(str(manager.environment_id), style="bold") + heading.append(", ") + elif manager.single_connection_mode: + heading.append("(single connection mode)", style="bold") + heading.append(", ") - # get params - environment_id = params['environment_id'] - commands = params['commands'] - mode = params['settings'].get('mode', 'remote') + if manager.connection and manager.host: + heading.append(str(manager.host), style="red") + heading.append(", ") - # get manager - manager = get_manager(environment_id, mode) + heading.append("mode: ") + heading.append(str(mode), style="green") + heading.append(", hostname: ") + heading.append(hostname, style="blue") - if len(params['commands']) == 0: - CLI.error('Missing commands. Check mantis --help for more information.') + console.print(heading) - if mode not in ['remote', 'ssh', 'host']: - CLI.error('Incorrect mode. Check mantis --help for more information.') - hostname = os.popen('hostname').read().rstrip("\n") +def parse_command_args(args: List[str]) -> tuple: + """ + Parse command line arguments to extract environment, commands, and their args. - # check config settings - settings_config = params['settings'].get('config', None) + Returns: (environment_id, command_groups) + where command_groups is a list of (command_name, args) tuples + """ + if not args: + return None, [] + + # Check if first arg is an environment (doesn't look like a command or option) + first_arg = args[0] + environment_id = None + start_idx = 0 + + # If first arg doesn't start with '-' and isn't a known command, treat as environment + if not first_arg.startswith('-') and first_arg not in COMMANDS: + environment_id = first_arg + start_idx = 1 + + # Parse remaining args into command groups + command_groups = [] + current_command = None + current_args = [] + + for arg in args[start_idx:]: + if arg.startswith('--'): + # Long option for current command + current_args.append(arg) + elif arg.startswith('-') and len(arg) == 2: + # Could be a shortcut or a short option + if arg.lstrip('-') in COMMANDS or arg in COMMANDS: + # It's a command shortcut + if current_command: + command_groups.append((current_command, current_args)) + current_command = arg.lstrip('-') + current_args = [] + else: + # Short option for current command + current_args.append(arg) + elif arg in COMMANDS: + # New command + if current_command: + command_groups.append((current_command, current_args)) + current_command = arg + current_args = [] + else: + # Argument for current command, or first command + if current_command is None: + # First positional arg is a command + if arg in COMMANDS: + current_command = arg + else: + # Unknown - could be command or arg + current_command = arg + else: + current_args.append(arg) - if settings_config: - # override manager config - for override_config in settings_config.split(','): - key, value = override_config.split('=') - nested_set( - dic=manager.config, - keys=key.split('.'), - value=value - ) + # Don't forget the last command + if current_command: + command_groups.append((current_command, current_args)) - console = Console() + return environment_id, command_groups - heading = Text.assemble( - version_info, ", ", - ("Environment ID = ", "") if manager.environment_id else ("(single connection mode), ", "bold") if manager.single_connection_mode else ("", ""), - (str(manager.environment_id) + ", ", "bold") if manager.environment_id else ("", ""), - (str(manager.host) + ", ", "red") if manager.connection and manager.host else ("", ""), - "mode: ", (str(manager.mode), "green"), - ", hostname: ", (hostname, "blue") - ) - console.print(heading) +def execute_commands(manager, command_groups: List[tuple], mode: str, environment_id: str, raw_args: List[str]): + """Execute a list of commands.""" + # Handle SSH mode specially - forward all commands to remote if mode == 'ssh': - # Build mantis command - environment_id is optional in single connection mode env_part = f'{environment_id} ' if environment_id else '' - cmds = [ - f'cd {manager.project_path}', - f'mantis {env_part}--mode=host {" ".join(commands)}' - ] - cmd = ';'.join(cmds) - exec = f"ssh -t {manager.user}@{manager.host} -p {manager.port} '{cmd}'" - os.system(exec) - else: - # execute all commands - for command in commands: - if ':' in command: - command, params = command.split(':') - params = params.split(',') - else: - params = [] - - execute(manager, command, params) - -def get_class_commands(cls, exclude_from=None): - """ - Extract commands from a class for help display. - Returns list of tuples: (command_str, description) + # Reconstruct command string from groups + cmd_parts = [] + for cmd_name, cmd_args in command_groups: + cmd_parts.append(cmd_name) + cmd_parts.extend(cmd_args) + + remote_cmd = f'mantis --mode=host {env_part}{" ".join(cmd_parts)}' + ssh_cmd = f"ssh -t {manager.user}@{manager.host} -p {manager.port} 'cd {manager.project_path}; {remote_cmd}'" + os.system(ssh_cmd) + return + + # Execute each command locally + for cmd_name, cmd_args in command_groups: + cmd = get_command(cmd_name) + + if cmd is None: + CLI.error(f'Unknown command: {cmd_name}. Run "mantis commands" to see available commands.') + + # Check if command requires environment + if cmd.name not in NO_ENV_COMMANDS: + if not manager.single_connection_mode and manager.environment_id is None: + CLI.error(f'Command "{cmd.name}" requires an environment. Usage: mantis {cmd.name}') + + # Execute the command + try: + cmd.execute(manager, cmd_args) + except TypeError as e: + CLI.error(f'Error executing {cmd.name}: {e}') + + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + args: Optional[List[str]] = typer.Argument(None, help="Environment and commands"), + mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"), + version: bool = typer.Option(False, "--version", "-v", help="Show version and exit"), + help_flag: bool = typer.Option(False, "--help", "-h", help="Show help and exit"), +): """ - commands = [] - exclude_methods = dir(exclude_from) if exclude_from else [] - - methods = inspect.getmembers(cls, predicate=inspect.isfunction) - - for method_name, method in methods: - # skip private methods and excluded methods - if method_name.startswith('_') or method_name in exclude_methods: - continue - - command = method_name.replace('_', '-') + Mantis CLI - Docker deployment tool. - # Get the method signature - signature = inspect.signature(method) + Usage: mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... - # Parameters (skip 'self') - parameters = [p for p in signature.parameters.keys() if p != 'self'] - - # Check if parameters are optional - params_are_optional = True + Examples: + mantis production status + mantis production deploy --dirty + mantis stage build push deploy + mantis manage migrate + """ + # Handle --version + if version: + print_version() + raise typer.Exit() - for param_name, param in signature.parameters.items(): - if param_name == 'self': - continue - if param.default == inspect.Parameter.empty: - params_are_optional = False + # Handle --help or no arguments + if help_flag or not args: + print_help() + raise typer.Exit() - # Build command string - command = f"--{command}" - params_str = "" + # Validate mode + if mode not in ['remote', 'ssh', 'host']: + CLI.error(f'Invalid mode: {mode}. Must be one of: remote, ssh, host') - if parameters: - if not params_are_optional: - params_str += '[' + # Parse arguments + environment_id, command_groups = parse_command_args(args) - params_str += ':' + if not command_groups: + CLI.error('No commands specified. Run "mantis commands" to see available commands.') - params_str += ','.join(parameters) + # Check if any command doesn't require environment + first_cmd = command_groups[0][0] if command_groups else None + first_cmd_obj = get_command(first_cmd) if first_cmd else None - if not params_are_optional: - params_str += ']' + # Get manager (may prompt for config selection) + try: + manager = get_manager(environment_id, mode) + except SystemExit: + raise typer.Exit(1) - docs = method.__doc__ or '' + # Print heading + print_heading(manager, mode) - commands.append((f"{command}{params_str}", docs.strip())) + # Execute commands + execute_commands(manager, command_groups, mode, environment_id, args) - return commands +def print_help(): + """Print help message.""" + console = Console() -def help(): - print(f'\nUsage:\n\ - mantis [--mode=remote|ssh|host] [environment] --command[:params]') + print(f""" +Mantis v{VERSION} - Docker deployment tool - print('\nModes:\n\ - remote \truns commands remotely from local machine using DOCKER_HOST or DOCKER_CONTEXT (default)\n\ - ssh \tconnects to host via ssh and run all mantis commands on remote machine directly (mantis-cli needs to be installed on server)\n\ - host \truns mantis on host machine directly without invoking connection (used as proxy for ssh mode)') +Usage: + mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... - print(f'\nEnvironment:\n\ - Either "local" or any custom environment identifier defined as connection in your config file.\n\ - Optional when using single connection mode (config has "connection" instead of "connections").') +Options: + --mode, -m Execution mode: remote (default), ssh, host + --version, -v Show version and exit + --help, -h Show this help message - console = Console() +Modes: + remote Runs commands remotely using DOCKER_HOST or DOCKER_CONTEXT (default) + ssh Connects via SSH and runs mantis on remote machine + host Runs mantis directly on host (used as proxy for ssh mode) - # Base commands - print(f'\nCommands:') - table = Table(show_header=True, header_style="bold") - table.add_column("Command", style="cyan") - table.add_column("Description") +Environment: + Optional environment ID (e.g., production, staging, local) + Required for multi-environment configs, optional for single connection mode - for command, description in get_class_commands(BaseManager, exclude_from=AbstractManager): - table.add_row(command, description) +Examples: + mantis production status # Check container status + mantis production deploy # Full deployment + mantis production deploy --dirty # Deploy without cleanup + mantis stage build push deploy # Build, push, and deploy + mantis production logs web # View logs for 'web' container + mantis manage migrate # Run Django migration (single connection mode) - console.print(table) +Run 'mantis commands' to see all available commands. +""") - # Extension commands - extensions = [ - ('Django', Django), - ('Nginx', Nginx), - ('Postgres', Postgres), - ] - for ext_name, ext_class in extensions: - ext_commands = get_class_commands(ext_class) - if ext_commands: - print(f'\n{ext_name} extension:') - ext_table = Table(show_header=True, header_style="bold") - ext_table.add_column("Command", style="yellow") - ext_table.add_column("Description") +def run(): + """Entry point for the CLI.""" + app() - for command, description in ext_commands: - ext_table.add_row(command, description) - console.print(ext_table) +if __name__ == "__main__": + run() diff --git a/mantis/commands.py b/mantis/commands.py new file mode 100644 index 0000000..cb128ed --- /dev/null +++ b/mantis/commands.py @@ -0,0 +1,454 @@ +""" +Command registry for mantis-cli using Typer-style argument definitions. +""" +from typing import Callable, Dict, List, Optional +import typer + +# Global command registry +COMMANDS: Dict[str, 'Command'] = {} + +# Commands that don't require environment +NO_ENV_COMMANDS = {'generate-key', 'check-config', 'contexts', 'create-context', 'read-key', 'commands'} + + +class Command: + """Wraps a command function with metadata for argument parsing.""" + + def __init__(self, func: Callable, name: str, shortcuts: List[str] = None): + self.func = func + self.name = name + self.shortcuts = shortcuts or [] + self.doc = func.__doc__ or '' + + def execute(self, manager, args: List[str] = None): + """Execute command with the manager and optional arguments.""" + if args is None: + args = [] + + # For commands with no args, just call with manager + if not args: + return self.func(manager) + + # Parse args and call function + # Simple approach: pass args as positional parameters + return self.func(manager, *args) + + +def command(name: str = None, shortcuts: List[str] = None): + """Decorator to register a command.""" + def decorator(func): + cmd_name = name or func.__name__.replace('_', '-') + cmd = Command(func, cmd_name, shortcuts) + COMMANDS[cmd_name] = cmd + if shortcuts: + for shortcut in shortcuts: + COMMANDS[shortcut] = cmd + return func + return decorator + + +def get_command(name: str) -> Optional[Command]: + """Get a command by name or shortcut.""" + return COMMANDS.get(name) + + +def list_commands() -> Dict[str, Command]: + """Return all registered commands (excluding shortcuts).""" + return {name: cmd for name, cmd in COMMANDS.items() if len(name) > 2 or name == cmd.name} + + +# ============================================================================= +# Base Commands (from BaseManager) +# ============================================================================= + +@command(shortcuts=['s']) +def status(manager): + """Prints images and containers""" + manager.status() + + +@command(shortcuts=['d']) +def deploy(manager, *args): + """Runs deployment process""" + dirty = 'dirty' in args or '--dirty' in args + manager.deploy(dirty=dirty) + + +@command(shortcuts=['b']) +def build(manager, *args): + """Builds all services with Dockerfiles""" + params = ' '.join(args) if args else '' + manager.build(params) + + +@command(shortcuts=['p']) +def pull(manager, *args): + """Pulls required images for services""" + params = ' '.join(args) if args else '' + manager.pull(params) + + +@command(shortcuts=['u']) +def upload(manager): + """Uploads mantis config, compose file and environment files to server""" + manager.upload() + + +@command(shortcuts=['c']) +def clean(manager, *args): + """Clean images, containers, networks""" + params = ' '.join(args) if args else '' + manager.clean(params) + + +@command(shortcuts=['l']) +def logs(manager, *args): + """Prints logs of all or given project container""" + params = ' '.join(args) if args else None + manager.logs(params) + + +@command(shortcuts=['n']) +def networks(manager): + """Prints docker networks""" + manager.networks() + + +@command(shortcuts=['hc']) +def healthcheck(manager, container: str = None): + """Execute health-check of given project container""" + manager.healthcheck(container) + + +@command() +def up(manager, *args): + """Calls compose up (with optional params)""" + params = ' '.join(args) if args else '' + manager.up(params) + + +@command() +def down(manager, *args): + """Calls compose down (with optional params)""" + params = ' '.join(args) if args else '' + manager.down(params) + + +@command() +def restart(manager, service: str = None): + """Restarts all containers by calling compose down and up""" + manager.restart(service) + + +@command() +def stop(manager, *args): + """Stops all or given project container""" + params = ' '.join(args) if args else None + manager.stop(params) + + +@command() +def start(manager, *args): + """Starts all or given project container""" + params = ' '.join(args) if args else '' + manager.start(params) + + +@command() +def kill(manager, *args): + """Kills all or given project container""" + params = ' '.join(args) if args else None + manager.kill(params) + + +@command() +def remove(manager, *args): + """Removes all or given project container""" + params = ' '.join(args) if args else '' + manager.remove(params) + + +@command() +def run(manager, *args): + """Calls compose run with params""" + if not args: + typer.echo("Error: run requires parameters", err=True) + raise typer.Exit(1) + params = ' '.join(args) + manager.run(params) + + +@command() +def bash(manager, container: str): + """Runs bash in container""" + if not container: + typer.echo("Error: bash requires container name", err=True) + raise typer.Exit(1) + manager.bash(container) + + +@command() +def sh(manager, container: str): + """Runs sh in container""" + if not container: + typer.echo("Error: sh requires container name", err=True) + raise typer.Exit(1) + manager.sh(container) + + +@command() +def ssh(manager): + """Connects to remote host via SSH""" + manager.ssh() + + +@command(name='exec') +def exec_cmd(manager, *args): + """Executes command in container""" + if not args: + typer.echo("Error: exec requires container and command", err=True) + raise typer.Exit(1) + params = ' '.join(args) + manager.exec(params) + + +@command(name='exec-it') +def exec_it(manager, *args): + """Executes command in container using interactive pseudo-TTY""" + if not args: + typer.echo("Error: exec-it requires container and command", err=True) + raise typer.Exit(1) + params = ' '.join(args) + manager.exec_it(params) + + +@command() +def scale(manager, service: str, num: str): + """Scales service to given scale""" + if not service or not num: + typer.echo("Error: scale requires service and number", err=True) + raise typer.Exit(1) + manager.scale(service, int(num)) + + +@command() +def push(manager, *args): + """Push built images to repository""" + params = ' '.join(args) if args else '' + manager.push(params) + + +@command(name='zero-downtime') +def zero_downtime(manager, service: str = None): + """Runs zero-downtime deployment of services (or given service)""" + manager.zero_downtime(service) + + +@command(name='restart-service') +def restart_service(manager, service: str): + """Stops, removes and recreates container for given service""" + if not service: + typer.echo("Error: restart-service requires service name", err=True) + raise typer.Exit(1) + manager.restart_service(service) + + +@command(name='remove-suffixes') +def remove_suffixes(manager, prefix: str = ''): + """Removes numerical suffixes from container names (if scale == 1)""" + manager.remove_suffixes(prefix) + + +@command(name='try-to-reload-webserver') +def try_to_reload_webserver(manager): + """Tries to reload webserver (if suitable extension is available)""" + manager.try_to_reload_webserver() + + +# Encryption commands + +@command(name='encrypt-env') +def encrypt_env(manager, *args): + """Encrypts all environment files (force param skips user confirmation)""" + params = 'force' if 'force' in args or '--force' in args else '' + manager.encrypt_env(params=params) + + +@command(name='decrypt-env') +def decrypt_env(manager, *args): + """Decrypts all environment files (force param skips user confirmation)""" + params = 'force' if 'force' in args or '--force' in args else '' + manager.decrypt_env(params=params) + + +@command(name='check-env') +def check_env(manager): + """Compares encrypted and decrypted env files""" + manager.check_env() + + +@command(name='generate-key') +def generate_key(manager): + """Creates new encryption key""" + manager.generate_key() + + +@command(name='read-key') +def read_key(manager): + """Returns value of mantis encryption key""" + print(manager.read_key()) + + +# Config commands + +@command(name='check-config') +def check_config(manager): + """Validates config file according to template""" + manager.check_config() + + +# Docker context commands + +@command() +def contexts(manager): + """Prints all docker contexts""" + manager.contexts() + + +@command(name='create-context') +def create_context(manager): + """Creates docker context using user inputs""" + manager.create_context() + + +# Service info commands + +@command() +def services(manager): + """Returns all defined services""" + for service in manager.services(): + print(service) + + +@command(name='services-to-build') +def services_to_build(manager): + """Prints all services which will be build""" + for service, info in manager.services_to_build().items(): + print(f"{service}: {info}") + + +@command(name='get-container-name') +def get_container_name(manager, service: str): + """Constructs container name with project prefix for given service""" + print(manager.get_container_name(service)) + + +@command(name='get-container-suffix') +def get_container_suffix(manager, service: str): + """Returns the suffix used for containers for given service""" + print(manager.get_container_suffix(service)) + + +@command(name='get-image-name') +def get_image_name(manager, service: str): + """Constructs image name for given service""" + print(manager.get_image_name(service)) + + +@command(name='get-image-suffix') +def get_image_suffix(manager, service: str): + """Returns the suffix used for image for given service""" + print(manager.get_image_suffix(service)) + + +@command(name='get-service-containers') +def get_service_containers(manager, service: str): + """Prints container names of given service""" + for container in manager.get_service_containers(service): + print(container) + + +@command(name='get-number-of-containers') +def get_number_of_containers(manager, service: str): + """Prints number of containers for given service""" + print(manager.get_number_of_containers(service)) + + +@command(name='get-deploy-replicas') +def get_deploy_replicas(manager, service: str): + """Returns default number of deploy replicas of given services""" + print(manager.get_deploy_replicas(service)) + + +# Health check commands + +@command(name='has-healthcheck') +def has_healthcheck(manager, container: str): + """Checks if given container has defined healthcheck""" + print(manager.has_healthcheck(container)) + + +@command(name='get-healthcheck-config') +def get_healthcheck_config(manager, container: str): + """Prints health-check config (if any) of given container""" + print(manager.get_healthcheck_config(container)) + + +@command(name='get-healthcheck-start-period') +def get_healthcheck_start_period(manager, container: str): + """Returns healthcheck start period for given container (if any)""" + print(manager.get_healthcheck_start_period(container)) + + +@command(name='check-health') +def check_health(manager, container: str): + """Checks current health of given container""" + result = manager.check_health(container) + if result: + is_healthy, status = result + print(f"Healthy: {is_healthy}, Status: {status}") + + +# Volume commands + +@command(name='backup-volume') +def backup_volume(manager, volume: str): + """Backups volume to a file""" + if not volume: + typer.echo("Error: backup-volume requires volume name", err=True) + raise typer.Exit(1) + manager.backup_volume(volume) + + +@command(name='restore-volume') +def restore_volume(manager, volume: str, file: str): + """Restores volume from a file""" + if not volume or not file: + typer.echo("Error: restore-volume requires volume and file", err=True) + raise typer.Exit(1) + manager.restore_volume(volume, file) + + +# Help command + +@command() +def commands(manager): + """Lists all available commands""" + from rich.console import Console + from rich.table import Table + + console = Console() + table = Table(show_header=True, header_style="bold") + table.add_column("Command", style="cyan") + table.add_column("Shortcuts", style="yellow") + table.add_column("Description") + + # Get unique commands (not shortcuts) + seen = set() + for name, cmd in sorted(COMMANDS.items()): + if cmd.name not in seen: + seen.add(cmd.name) + shortcuts = ', '.join(cmd.shortcuts) if cmd.shortcuts else '' + table.add_row(cmd.name, shortcuts, cmd.doc.strip()) + + console.print(table) diff --git a/mantis/extensions/django.py b/mantis/extensions/django.py index 8215ddc..198a8a2 100644 --- a/mantis/extensions/django.py +++ b/mantis/extensions/django.py @@ -1,4 +1,5 @@ from mantis.helpers import CLI +from mantis.commands import command class Django(): @@ -11,10 +12,10 @@ def django_container(self): if container_name_with_suffix in self.get_containers(): return container_name_with_suffix - + if container_name in self.get_containers(): return container_name - + CLI.error(f"Container {container_name} not found") def shell(self): @@ -37,3 +38,25 @@ def send_test_email(self): """ CLI.info('Sending test email...') self.docker(f'exec -i {self.django_container} python manage.py sendtestemail --admins') + + +# Register extension commands +@command() +def shell(manager): + """Runs and connects to Django shell""" + manager.shell() + + +@command() +def manage(manager, *args): + """Runs Django manage command""" + if not args: + CLI.error("manage requires a command argument (e.g., 'mantis production manage migrate')") + params = ' '.join(args) + manager.manage(params) + + +@command(name='send-test-email') +def send_test_email(manager): + """Sends test email to admins using Django 'sendtestemail' command""" + manager.send_test_email() diff --git a/mantis/extensions/nginx.py b/mantis/extensions/nginx.py index b510bfd..3ce098a 100644 --- a/mantis/extensions/nginx.py +++ b/mantis/extensions/nginx.py @@ -1,4 +1,5 @@ from mantis.helpers import CLI +from mantis.commands import command class Nginx(): @@ -14,3 +15,10 @@ def reload_webserver(self): """ CLI.info('Reloading nginx...') self.docker(f'exec {self.nginx_container} nginx -s reload') + + +# Register extension commands +@command(name='reload-webserver') +def reload_webserver(manager): + """Reloads nginx webserver""" + manager.reload_webserver() diff --git a/mantis/extensions/postgres.py b/mantis/extensions/postgres.py index ce061db..0d4b7f4 100644 --- a/mantis/extensions/postgres.py +++ b/mantis/extensions/postgres.py @@ -1,6 +1,7 @@ import datetime from mantis.helpers import CLI +from mantis.commands import command class Postgres(): @@ -76,3 +77,74 @@ def pg_restore_data(self, params): """ filename, table = params.split(',') self.pg_restore(filename=filename, table=table) + + +# Register extension commands +@command() +def psql(manager): + """Starts psql console""" + manager.psql() + + +@command(name='pg-dump') +def pg_dump_cmd(manager, *args): + """Backups PostgreSQL database""" + data_only = '--data-only' in args or '-d' in args + table = None + for i, arg in enumerate(args): + if arg in ('--table', '-t') and i + 1 < len(args): + table = args[i + 1] + elif arg.startswith('--table='): + table = arg.split('=')[1] + manager.pg_dump(data_only=data_only, table=table) + + +@command(name='pg-dump-data') +def pg_dump_data_cmd(manager, *args): + """Backups PostgreSQL database [data only]""" + table = None + for i, arg in enumerate(args): + if arg in ('--table', '-t') and i + 1 < len(args): + table = args[i + 1] + elif arg.startswith('--table='): + table = arg.split('=')[1] + elif not arg.startswith('-'): + table = arg + manager.pg_dump_data(table=table) + + +@command(name='pg-restore') +def pg_restore_cmd(manager, *args): + """Restores database from backup""" + if not args: + CLI.error("pg-restore requires a filename argument") + filename = None + table = None + positional_args = [] + i = 0 + while i < len(args): + arg = args[i] + if arg in ('--table', '-t') and i + 1 < len(args): + table = args[i + 1] + i += 2 + elif arg.startswith('--table='): + table = arg.split('=')[1] + i += 1 + elif not arg.startswith('-'): + positional_args.append(arg) + i += 1 + else: + i += 1 + if positional_args: + filename = positional_args[0] + if not filename: + CLI.error("pg-restore requires a filename argument") + manager.pg_restore(filename=filename, table=table) + + +@command(name='pg-restore-data') +def pg_restore_data_cmd(manager, filename: str, table: str): + """Restores database from backup [data only]""" + if not filename or not table: + CLI.error("pg-restore-data requires filename and table arguments") + manager.pg_restore(filename=filename, table=table) diff --git a/mantis/logic.py b/mantis/logic.py index 5cb7a16..d84f3fd 100644 --- a/mantis/logic.py +++ b/mantis/logic.py @@ -184,39 +184,3 @@ class MantisManager(*[manager_class] + extension_classes): setattr(manager, f'{extension}_service'.lower(), extension_params['service']) return manager - - -def execute(manager, command, params): - shortcuts = { - '-hc': 'healthcheck', - '-b': 'build', - '-p': 'pull', - '-u': 'upload', - '-d': 'deploy', - '-c': 'clean', - '-s': 'status', - '-n': 'networks', - '-l': 'logs', - } - - manager_method = shortcuts.get(command, None) - - if manager_method is None: - manager_method = command.lstrip('-').replace('-', '_') - - if manager_method is None or not hasattr(manager, manager_method): - CLI.error(f'Invalid command "{command}". Check mantis --help for more information.') - else: - methods_without_environment = ['contexts', 'create_context', 'check_config', 'generate_key', 'read_key'] - - # In single connection mode, environment_id is not required - if manager.environment_id is None and not manager.single_connection_mode and manager_method not in methods_without_environment: - CLI.error('Missing environment') - elif manager.environment_id is not None and manager_method in methods_without_environment: - CLI.error('Redundant environment') - - # Execute manager method - returned_value = getattr(manager, manager_method)(*params) - - if returned_value: - print(returned_value) diff --git a/setup.py b/setup.py index 59cbc46..2a4f585 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ url='https://github.com/PragmaticMates/mantis-cli', packages=find_packages(), include_package_data=True, - install_requires=['cffi', 'cryptography', 'pycryptodome', 'PyYAML', 'rich'], + install_requires=['cffi', 'cryptography', 'pycryptodome', 'PyYAML', 'rich', 'typer'], entry_points={ 'console_scripts': ['mantis=mantis.command_line:run'], }, From 1f5f00939224bbb8ff1cee5e55b4a003595ad924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Fri, 2 Jan 2026 21:30:03 +0100 Subject: [PATCH 02/22] improved typer version --- mantis/command_line.py | 629 ++++++++++++++++++++++++---------- mantis/commands.py | 454 ------------------------ mantis/extensions/django.py | 35 +- mantis/extensions/nginx.py | 12 +- mantis/extensions/postgres.py | 103 +----- 5 files changed, 452 insertions(+), 781 deletions(-) delete mode 100644 mantis/commands.py diff --git a/mantis/command_line.py b/mantis/command_line.py index 3916849..0165330 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -3,48 +3,40 @@ Mantis CLI - Docker deployment tool Usage: - mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... + mantis [OPTIONS] COMMAND [ARGS]... [COMMAND [ARGS]...] Examples: - mantis production status - mantis production deploy --dirty - mantis production build push deploy - mantis prod logs web + mantis -e production status + mantis -e production deploy --dirty + mantis -e production build push deploy + mantis status (single connection mode) mantis manage migrate """ +import inspect import os -import sys -from typing import List, Optional +from typing import Optional, List import typer from rich.console import Console from rich.text import Text from mantis import VERSION -from mantis.commands import COMMANDS, NO_ENV_COMMANDS, get_command, list_commands from mantis.helpers import CLI from mantis.logic import get_manager -app = typer.Typer( - add_completion=False, - invoke_without_command=True, - no_args_is_help=False, -) +app = typer.Typer(chain=True, rich_markup_mode="rich") - -def print_version(): - """Print version information.""" - print(f"Mantis v{VERSION}") +# Commands that don't require environment +NO_ENV_COMMANDS = {'generate-key', 'check-config', 'contexts', 'create-context', 'read-key'} def print_heading(manager, mode: str): """Print the heading with environment and connection info.""" console = Console() hostname = os.popen('hostname').read().rstrip("\n") - version_info = f'Mantis v{VERSION}' heading = Text() - heading.append(version_info) + heading.append(f'Mantis v{VERSION}') heading.append(", ") if manager.environment_id: @@ -67,196 +59,459 @@ def print_heading(manager, mode: str): console.print(heading) -def parse_command_args(args: List[str]) -> tuple: - """ - Parse command line arguments to extract environment, commands, and their args. - - Returns: (environment_id, command_groups) - where command_groups is a list of (command_name, args) tuples - """ - if not args: - return None, [] - - # Check if first arg is an environment (doesn't look like a command or option) - first_arg = args[0] - environment_id = None - start_idx = 0 - - # If first arg doesn't start with '-' and isn't a known command, treat as environment - if not first_arg.startswith('-') and first_arg not in COMMANDS: - environment_id = first_arg - start_idx = 1 - - # Parse remaining args into command groups - command_groups = [] - current_command = None - current_args = [] - - for arg in args[start_idx:]: - if arg.startswith('--'): - # Long option for current command - current_args.append(arg) - elif arg.startswith('-') and len(arg) == 2: - # Could be a shortcut or a short option - if arg.lstrip('-') in COMMANDS or arg in COMMANDS: - # It's a command shortcut - if current_command: - command_groups.append((current_command, current_args)) - current_command = arg.lstrip('-') - current_args = [] - else: - # Short option for current command - current_args.append(arg) - elif arg in COMMANDS: - # New command - if current_command: - command_groups.append((current_command, current_args)) - current_command = arg - current_args = [] - else: - # Argument for current command, or first command - if current_command is None: - # First positional arg is a command - if arg in COMMANDS: - current_command = arg - else: - # Unknown - could be command or arg - current_command = arg - else: - current_args.append(arg) - - # Don't forget the last command - if current_command: - command_groups.append((current_command, current_args)) - - return environment_id, command_groups - - -def execute_commands(manager, command_groups: List[tuple], mode: str, environment_id: str, raw_args: List[str]): - """Execute a list of commands.""" - # Handle SSH mode specially - forward all commands to remote - if mode == 'ssh': - env_part = f'{environment_id} ' if environment_id else '' - # Reconstruct command string from groups - cmd_parts = [] - for cmd_name, cmd_args in command_groups: - cmd_parts.append(cmd_name) - cmd_parts.extend(cmd_args) - - remote_cmd = f'mantis --mode=host {env_part}{" ".join(cmd_parts)}' - ssh_cmd = f"ssh -t {manager.user}@{manager.host} -p {manager.port} 'cd {manager.project_path}; {remote_cmd}'" - os.system(ssh_cmd) - return - - # Execute each command locally - for cmd_name, cmd_args in command_groups: - cmd = get_command(cmd_name) - - if cmd is None: - CLI.error(f'Unknown command: {cmd_name}. Run "mantis commands" to see available commands.') - - # Check if command requires environment - if cmd.name not in NO_ENV_COMMANDS: - if not manager.single_connection_mode and manager.environment_id is None: - CLI.error(f'Command "{cmd.name}" requires an environment. Usage: mantis {cmd.name}') - - # Execute the command - try: - cmd.execute(manager, cmd_args) - except TypeError as e: - CLI.error(f'Error executing {cmd.name}: {e}') - - -@app.callback(invoke_without_command=True) +class State: + """Shared state across commands.""" + def __init__(self): + self._manager = None + self._mode = 'remote' + self._heading_printed = False + + def _ensure_ready(self, command_name: str): + """Print heading and validate environment.""" + if not self._heading_printed: + print_heading(self._manager, self._mode) + self._heading_printed = True + + if command_name not in NO_ENV_COMMANDS: + if not self._manager.single_connection_mode and self._manager.environment_id is None: + CLI.error(f'Command "{command_name}" requires environment. Use: mantis -e {command_name}') + + def __getattr__(self, name): + """Delegate method calls to manager, handling heading and validation.""" + # Get the caller function name (the command) + caller = inspect.stack()[1].function + command_name = caller.replace('_', '-') + self._ensure_ready(command_name) + return getattr(self._manager, name) + + +state = State() + + +@app.callback() def main( - ctx: typer.Context, - args: Optional[List[str]] = typer.Argument(None, help="Environment and commands"), + environment: Optional[str] = typer.Option(None, "--env", "-e", help="Environment ID"), mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"), version: bool = typer.Option(False, "--version", "-v", help="Show version and exit"), - help_flag: bool = typer.Option(False, "--help", "-h", help="Show help and exit"), ): - """ - Mantis CLI - Docker deployment tool. - - Usage: mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... - - Examples: - mantis production status - mantis production deploy --dirty - mantis stage build push deploy - mantis manage migrate - """ - # Handle --version + """Mantis CLI - Docker deployment tool.""" if version: - print_version() + typer.echo(f"Mantis v{VERSION}") raise typer.Exit() - # Handle --help or no arguments - if help_flag or not args: - print_help() - raise typer.Exit() + state._mode = mode + state._manager = get_manager(environment, mode) - # Validate mode - if mode not in ['remote', 'ssh', 'host']: - CLI.error(f'Invalid mode: {mode}. Must be one of: remote, ssh, host') - # Parse arguments - environment_id, command_groups = parse_command_args(args) +# ============================================================================= +# Core Commands +# ============================================================================= - if not command_groups: - CLI.error('No commands specified. Run "mantis commands" to see available commands.') +@app.command() +def status(): + """Prints images and containers""" + state.status() - # Check if any command doesn't require environment - first_cmd = command_groups[0][0] if command_groups else None - first_cmd_obj = get_command(first_cmd) if first_cmd else None - # Get manager (may prompt for config selection) - try: - manager = get_manager(environment_id, mode) - except SystemExit: - raise typer.Exit(1) +@app.command() +def deploy( + dirty: bool = typer.Option(False, "--dirty", help="Skip clean step"), +): + """Runs deployment process""" + state.deploy(dirty=dirty) - # Print heading - print_heading(manager, mode) - # Execute commands - execute_commands(manager, command_groups, mode, environment_id, args) +@app.command() +def build( + services: Optional[List[str]] = typer.Argument(None, help="Services to build"), +): + """Builds all services with Dockerfiles""" + state.build(' '.join(services) if services else '') -def print_help(): - """Print help message.""" - console = Console() +@app.command() +def pull( + services: Optional[List[str]] = typer.Argument(None, help="Services to pull"), +): + """Pulls required images for services""" + state.pull(' '.join(services) if services else '') - print(f""" -Mantis v{VERSION} - Docker deployment tool -Usage: - mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... +@app.command() +def push( + services: Optional[List[str]] = typer.Argument(None, help="Services to push"), +): + """Push built images to repository""" + state.push(' '.join(services) if services else '') -Options: - --mode, -m Execution mode: remote (default), ssh, host - --version, -v Show version and exit - --help, -h Show this help message -Modes: - remote Runs commands remotely using DOCKER_HOST or DOCKER_CONTEXT (default) - ssh Connects via SSH and runs mantis on remote machine - host Runs mantis directly on host (used as proxy for ssh mode) +@app.command() +def upload(): + """Uploads config, compose and environment files to server""" + state.upload() -Environment: - Optional environment ID (e.g., production, staging, local) - Required for multi-environment configs, optional for single connection mode -Examples: - mantis production status # Check container status - mantis production deploy # Full deployment - mantis production deploy --dirty # Deploy without cleanup - mantis stage build push deploy # Build, push, and deploy - mantis production logs web # View logs for 'web' container - mantis manage migrate # Run Django migration (single connection mode) - -Run 'mantis commands' to see all available commands. -""") +@app.command() +def clean( + params: Optional[List[str]] = typer.Argument(None, help="Clean parameters"), +): + """Clean images, containers, networks""" + state.clean(' '.join(params) if params else '') + + +@app.command() +def logs( + container: Optional[str] = typer.Argument(None, help="Container name"), +): + """Prints logs of containers""" + state.logs(container) + + +@app.command() +def networks(): + """Prints docker networks""" + state.networks() + + +@app.command() +def healthcheck( + container: Optional[str] = typer.Argument(None, help="Container name"), +): + """Execute health-check of container""" + state.healthcheck(container) + + +@app.command() +def up( + params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), +): + """Calls compose up""" + state.up(' '.join(params) if params else '') + + +@app.command() +def down( + params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), +): + """Calls compose down""" + state.down(' '.join(params) if params else '') + + +@app.command() +def restart( + service: Optional[str] = typer.Argument(None, help="Service to restart"), +): + """Restarts containers""" + state.restart(service) + + +@app.command() +def stop( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to stop"), +): + """Stops containers""" + state.stop(' '.join(containers) if containers else None) + + +@app.command() +def start( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), +): + """Starts containers""" + state.start(' '.join(containers) if containers else '') + + +@app.command() +def kill( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), +): + """Kills containers""" + state.kill(' '.join(containers) if containers else None) + + +@app.command() +def remove( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to remove"), +): + """Removes containers""" + state.remove(' '.join(containers) if containers else '') + + +@app.command("run") +def run_cmd( + params: List[str] = typer.Argument(..., help="Compose run parameters"), +): + """Calls compose run with params""" + state.run(' '.join(params)) + + +@app.command() +def bash( + container: str = typer.Argument(..., help="Container name"), +): + """Runs bash in container""" + state.bash(container) + + +@app.command() +def sh( + container: str = typer.Argument(..., help="Container name"), +): + """Runs sh in container""" + state.sh(container) + + +@app.command("ssh") +def ssh_cmd(): + """Connects to remote host via SSH""" + state.ssh() + + +@app.command("exec") +def exec_cmd( + container: str = typer.Argument(..., help="Container name"), + command: List[str] = typer.Argument(..., help="Command to execute"), +): + """Executes command in container""" + state.exec(f"{container} {' '.join(command)}") + + +@app.command("exec-it") +def exec_it( + container: str = typer.Argument(..., help="Container name"), + command: List[str] = typer.Argument(..., help="Command to execute"), +): + """Executes command in container (interactive)""" + state.exec_it(f"{container} {' '.join(command)}") + + +@app.command() +def scale( + service: str = typer.Argument(..., help="Service name"), + num: int = typer.Argument(..., help="Number of instances"), +): + """Scales service to given number""" + state.scale(service, num) + + +@app.command("zero-downtime") +def zero_downtime( + service: Optional[str] = typer.Argument(None, help="Service name"), +): + """Runs zero-downtime deployment""" + state.zero_downtime(service) + + +@app.command("restart-service") +def restart_service( + service: str = typer.Argument(..., help="Service name"), +): + """Restarts a specific service""" + state.restart_service(service) + + +@app.command("remove-suffixes") +def remove_suffixes( + prefix: str = typer.Argument("", help="Prefix to match"), +): + """Removes numerical suffixes from container names""" + state.remove_suffixes(prefix) + + +# ============================================================================= +# Encryption Commands +# ============================================================================= + +@app.command("encrypt-env") +def encrypt_env( + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Encrypts environment files""" + state.encrypt_env(params='force' if force else '') + + +@app.command("decrypt-env") +def decrypt_env( + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Decrypts environment files""" + state.decrypt_env(params='force' if force else '') + + +@app.command("check-env") +def check_env(): + """Compares encrypted and decrypted env files""" + state.check_env() + + +@app.command("generate-key") +def generate_key(): + """Creates new encryption key""" + state.generate_key() + + +@app.command("read-key") +def read_key(): + """Returns encryption key value""" + print(state.read_key()) + + +# ============================================================================= +# Config Commands +# ============================================================================= + +@app.command("check-config") +def check_config(): + """Validates config file""" + state.check_config() + + +@app.command() +def contexts(): + """Prints all docker contexts""" + state.contexts() + + +@app.command("create-context") +def create_context(): + """Creates docker context""" + state.create_context() + + +# ============================================================================= +# Service Info Commands +# ============================================================================= + +@app.command() +def services(): + """Lists all defined services""" + for service in state.services(): + print(service) + + +@app.command("services-to-build") +def services_to_build(): + """Lists services that will be built""" + for service, info in state.services_to_build().items(): + print(f"{service}: {info}") + + +@app.command("get-container-name") +def get_container_name( + service: str = typer.Argument(..., help="Service name"), +): + """Gets container name for service""" + print(state.get_container_name(service)) + + +@app.command("get-image-name") +def get_image_name( + service: str = typer.Argument(..., help="Service name"), +): + """Gets image name for service""" + print(state.get_image_name(service)) + + +# ============================================================================= +# Volume Commands +# ============================================================================= + +@app.command("backup-volume") +def backup_volume( + volume: str = typer.Argument(..., help="Volume name"), +): + """Backups volume to a file""" + state.backup_volume(volume) + + +@app.command("restore-volume") +def restore_volume( + volume: str = typer.Argument(..., help="Volume name"), + file: str = typer.Argument(..., help="Backup file"), +): + """Restores volume from a file""" + state.restore_volume(volume, file) + + +# ============================================================================= +# Django Extension Commands +# ============================================================================= + +@app.command() +def shell(): + """Runs Django shell""" + state.shell() + + +@app.command() +def manage( + command: str = typer.Argument(..., help="Django management command"), + args: Optional[List[str]] = typer.Argument(None, help="Command arguments"), +): + """Runs Django manage command""" + full_cmd = command + (' ' + ' '.join(args) if args else '') + state.manage(full_cmd) + + +@app.command("send-test-email") +def send_test_email(): + """Sends test email to admins""" + state.send_test_email() + + +# ============================================================================= +# PostgreSQL Extension Commands +# ============================================================================= + +@app.command() +def psql(): + """Starts psql console""" + state.psql() + + +@app.command("pg-dump") +def pg_dump( + data_only: bool = typer.Option(False, "--data-only", "-d", help="Dump data only"), + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Backups PostgreSQL database""" + state.pg_dump(data_only=data_only, table=table) + + +@app.command("pg-dump-data") +def pg_dump_data( + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Backups PostgreSQL database (data only)""" + state.pg_dump_data(table=table) + + +@app.command("pg-restore") +def pg_restore( + filename: str = typer.Argument(..., help="Backup filename"), + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Restores database from backup""" + state.pg_restore(filename=filename, table=table) + + +@app.command("pg-restore-data") +def pg_restore_data( + filename: str = typer.Argument(..., help="Backup filename"), + table: str = typer.Argument(..., help="Table name"), +): + """Restores database data from backup""" + state.pg_restore(filename=filename, table=table) + + +# ============================================================================= +# Nginx Extension Commands +# ============================================================================= + +@app.command("reload-webserver") +def reload_webserver(): + """Reloads nginx webserver""" + state.reload_webserver() def run(): diff --git a/mantis/commands.py b/mantis/commands.py deleted file mode 100644 index cb128ed..0000000 --- a/mantis/commands.py +++ /dev/null @@ -1,454 +0,0 @@ -""" -Command registry for mantis-cli using Typer-style argument definitions. -""" -from typing import Callable, Dict, List, Optional -import typer - -# Global command registry -COMMANDS: Dict[str, 'Command'] = {} - -# Commands that don't require environment -NO_ENV_COMMANDS = {'generate-key', 'check-config', 'contexts', 'create-context', 'read-key', 'commands'} - - -class Command: - """Wraps a command function with metadata for argument parsing.""" - - def __init__(self, func: Callable, name: str, shortcuts: List[str] = None): - self.func = func - self.name = name - self.shortcuts = shortcuts or [] - self.doc = func.__doc__ or '' - - def execute(self, manager, args: List[str] = None): - """Execute command with the manager and optional arguments.""" - if args is None: - args = [] - - # For commands with no args, just call with manager - if not args: - return self.func(manager) - - # Parse args and call function - # Simple approach: pass args as positional parameters - return self.func(manager, *args) - - -def command(name: str = None, shortcuts: List[str] = None): - """Decorator to register a command.""" - def decorator(func): - cmd_name = name or func.__name__.replace('_', '-') - cmd = Command(func, cmd_name, shortcuts) - COMMANDS[cmd_name] = cmd - if shortcuts: - for shortcut in shortcuts: - COMMANDS[shortcut] = cmd - return func - return decorator - - -def get_command(name: str) -> Optional[Command]: - """Get a command by name or shortcut.""" - return COMMANDS.get(name) - - -def list_commands() -> Dict[str, Command]: - """Return all registered commands (excluding shortcuts).""" - return {name: cmd for name, cmd in COMMANDS.items() if len(name) > 2 or name == cmd.name} - - -# ============================================================================= -# Base Commands (from BaseManager) -# ============================================================================= - -@command(shortcuts=['s']) -def status(manager): - """Prints images and containers""" - manager.status() - - -@command(shortcuts=['d']) -def deploy(manager, *args): - """Runs deployment process""" - dirty = 'dirty' in args or '--dirty' in args - manager.deploy(dirty=dirty) - - -@command(shortcuts=['b']) -def build(manager, *args): - """Builds all services with Dockerfiles""" - params = ' '.join(args) if args else '' - manager.build(params) - - -@command(shortcuts=['p']) -def pull(manager, *args): - """Pulls required images for services""" - params = ' '.join(args) if args else '' - manager.pull(params) - - -@command(shortcuts=['u']) -def upload(manager): - """Uploads mantis config, compose file and environment files to server""" - manager.upload() - - -@command(shortcuts=['c']) -def clean(manager, *args): - """Clean images, containers, networks""" - params = ' '.join(args) if args else '' - manager.clean(params) - - -@command(shortcuts=['l']) -def logs(manager, *args): - """Prints logs of all or given project container""" - params = ' '.join(args) if args else None - manager.logs(params) - - -@command(shortcuts=['n']) -def networks(manager): - """Prints docker networks""" - manager.networks() - - -@command(shortcuts=['hc']) -def healthcheck(manager, container: str = None): - """Execute health-check of given project container""" - manager.healthcheck(container) - - -@command() -def up(manager, *args): - """Calls compose up (with optional params)""" - params = ' '.join(args) if args else '' - manager.up(params) - - -@command() -def down(manager, *args): - """Calls compose down (with optional params)""" - params = ' '.join(args) if args else '' - manager.down(params) - - -@command() -def restart(manager, service: str = None): - """Restarts all containers by calling compose down and up""" - manager.restart(service) - - -@command() -def stop(manager, *args): - """Stops all or given project container""" - params = ' '.join(args) if args else None - manager.stop(params) - - -@command() -def start(manager, *args): - """Starts all or given project container""" - params = ' '.join(args) if args else '' - manager.start(params) - - -@command() -def kill(manager, *args): - """Kills all or given project container""" - params = ' '.join(args) if args else None - manager.kill(params) - - -@command() -def remove(manager, *args): - """Removes all or given project container""" - params = ' '.join(args) if args else '' - manager.remove(params) - - -@command() -def run(manager, *args): - """Calls compose run with params""" - if not args: - typer.echo("Error: run requires parameters", err=True) - raise typer.Exit(1) - params = ' '.join(args) - manager.run(params) - - -@command() -def bash(manager, container: str): - """Runs bash in container""" - if not container: - typer.echo("Error: bash requires container name", err=True) - raise typer.Exit(1) - manager.bash(container) - - -@command() -def sh(manager, container: str): - """Runs sh in container""" - if not container: - typer.echo("Error: sh requires container name", err=True) - raise typer.Exit(1) - manager.sh(container) - - -@command() -def ssh(manager): - """Connects to remote host via SSH""" - manager.ssh() - - -@command(name='exec') -def exec_cmd(manager, *args): - """Executes command in container""" - if not args: - typer.echo("Error: exec requires container and command", err=True) - raise typer.Exit(1) - params = ' '.join(args) - manager.exec(params) - - -@command(name='exec-it') -def exec_it(manager, *args): - """Executes command in container using interactive pseudo-TTY""" - if not args: - typer.echo("Error: exec-it requires container and command", err=True) - raise typer.Exit(1) - params = ' '.join(args) - manager.exec_it(params) - - -@command() -def scale(manager, service: str, num: str): - """Scales service to given scale""" - if not service or not num: - typer.echo("Error: scale requires service and number", err=True) - raise typer.Exit(1) - manager.scale(service, int(num)) - - -@command() -def push(manager, *args): - """Push built images to repository""" - params = ' '.join(args) if args else '' - manager.push(params) - - -@command(name='zero-downtime') -def zero_downtime(manager, service: str = None): - """Runs zero-downtime deployment of services (or given service)""" - manager.zero_downtime(service) - - -@command(name='restart-service') -def restart_service(manager, service: str): - """Stops, removes and recreates container for given service""" - if not service: - typer.echo("Error: restart-service requires service name", err=True) - raise typer.Exit(1) - manager.restart_service(service) - - -@command(name='remove-suffixes') -def remove_suffixes(manager, prefix: str = ''): - """Removes numerical suffixes from container names (if scale == 1)""" - manager.remove_suffixes(prefix) - - -@command(name='try-to-reload-webserver') -def try_to_reload_webserver(manager): - """Tries to reload webserver (if suitable extension is available)""" - manager.try_to_reload_webserver() - - -# Encryption commands - -@command(name='encrypt-env') -def encrypt_env(manager, *args): - """Encrypts all environment files (force param skips user confirmation)""" - params = 'force' if 'force' in args or '--force' in args else '' - manager.encrypt_env(params=params) - - -@command(name='decrypt-env') -def decrypt_env(manager, *args): - """Decrypts all environment files (force param skips user confirmation)""" - params = 'force' if 'force' in args or '--force' in args else '' - manager.decrypt_env(params=params) - - -@command(name='check-env') -def check_env(manager): - """Compares encrypted and decrypted env files""" - manager.check_env() - - -@command(name='generate-key') -def generate_key(manager): - """Creates new encryption key""" - manager.generate_key() - - -@command(name='read-key') -def read_key(manager): - """Returns value of mantis encryption key""" - print(manager.read_key()) - - -# Config commands - -@command(name='check-config') -def check_config(manager): - """Validates config file according to template""" - manager.check_config() - - -# Docker context commands - -@command() -def contexts(manager): - """Prints all docker contexts""" - manager.contexts() - - -@command(name='create-context') -def create_context(manager): - """Creates docker context using user inputs""" - manager.create_context() - - -# Service info commands - -@command() -def services(manager): - """Returns all defined services""" - for service in manager.services(): - print(service) - - -@command(name='services-to-build') -def services_to_build(manager): - """Prints all services which will be build""" - for service, info in manager.services_to_build().items(): - print(f"{service}: {info}") - - -@command(name='get-container-name') -def get_container_name(manager, service: str): - """Constructs container name with project prefix for given service""" - print(manager.get_container_name(service)) - - -@command(name='get-container-suffix') -def get_container_suffix(manager, service: str): - """Returns the suffix used for containers for given service""" - print(manager.get_container_suffix(service)) - - -@command(name='get-image-name') -def get_image_name(manager, service: str): - """Constructs image name for given service""" - print(manager.get_image_name(service)) - - -@command(name='get-image-suffix') -def get_image_suffix(manager, service: str): - """Returns the suffix used for image for given service""" - print(manager.get_image_suffix(service)) - - -@command(name='get-service-containers') -def get_service_containers(manager, service: str): - """Prints container names of given service""" - for container in manager.get_service_containers(service): - print(container) - - -@command(name='get-number-of-containers') -def get_number_of_containers(manager, service: str): - """Prints number of containers for given service""" - print(manager.get_number_of_containers(service)) - - -@command(name='get-deploy-replicas') -def get_deploy_replicas(manager, service: str): - """Returns default number of deploy replicas of given services""" - print(manager.get_deploy_replicas(service)) - - -# Health check commands - -@command(name='has-healthcheck') -def has_healthcheck(manager, container: str): - """Checks if given container has defined healthcheck""" - print(manager.has_healthcheck(container)) - - -@command(name='get-healthcheck-config') -def get_healthcheck_config(manager, container: str): - """Prints health-check config (if any) of given container""" - print(manager.get_healthcheck_config(container)) - - -@command(name='get-healthcheck-start-period') -def get_healthcheck_start_period(manager, container: str): - """Returns healthcheck start period for given container (if any)""" - print(manager.get_healthcheck_start_period(container)) - - -@command(name='check-health') -def check_health(manager, container: str): - """Checks current health of given container""" - result = manager.check_health(container) - if result: - is_healthy, status = result - print(f"Healthy: {is_healthy}, Status: {status}") - - -# Volume commands - -@command(name='backup-volume') -def backup_volume(manager, volume: str): - """Backups volume to a file""" - if not volume: - typer.echo("Error: backup-volume requires volume name", err=True) - raise typer.Exit(1) - manager.backup_volume(volume) - - -@command(name='restore-volume') -def restore_volume(manager, volume: str, file: str): - """Restores volume from a file""" - if not volume or not file: - typer.echo("Error: restore-volume requires volume and file", err=True) - raise typer.Exit(1) - manager.restore_volume(volume, file) - - -# Help command - -@command() -def commands(manager): - """Lists all available commands""" - from rich.console import Console - from rich.table import Table - - console = Console() - table = Table(show_header=True, header_style="bold") - table.add_column("Command", style="cyan") - table.add_column("Shortcuts", style="yellow") - table.add_column("Description") - - # Get unique commands (not shortcuts) - seen = set() - for name, cmd in sorted(COMMANDS.items()): - if cmd.name not in seen: - seen.add(cmd.name) - shortcuts = ', '.join(cmd.shortcuts) if cmd.shortcuts else '' - table.add_row(cmd.name, shortcuts, cmd.doc.strip()) - - console.print(table) diff --git a/mantis/extensions/django.py b/mantis/extensions/django.py index 198a8a2..9968c6d 100644 --- a/mantis/extensions/django.py +++ b/mantis/extensions/django.py @@ -1,5 +1,4 @@ from mantis.helpers import CLI -from mantis.commands import command class Django(): @@ -19,44 +18,16 @@ def django_container(self): CLI.error(f"Container {container_name} not found") def shell(self): - """ - Runs and connects to Django shell - """ + """Runs and connects to Django shell""" CLI.info('Connecting to Django shell...') self.docker(f'exec -i {self.django_container} python manage.py shell') def manage(self, params): - """ - Runs Django manage command - """ + """Runs Django manage command""" CLI.info('Django manage...') self.docker(f'exec -ti {self.django_container} python manage.py {params}') def send_test_email(self): - """ - Sends test email to admins using Django 'sendtestemail' command - """ + """Sends test email to admins""" CLI.info('Sending test email...') self.docker(f'exec -i {self.django_container} python manage.py sendtestemail --admins') - - -# Register extension commands -@command() -def shell(manager): - """Runs and connects to Django shell""" - manager.shell() - - -@command() -def manage(manager, *args): - """Runs Django manage command""" - if not args: - CLI.error("manage requires a command argument (e.g., 'mantis production manage migrate')") - params = ' '.join(args) - manager.manage(params) - - -@command(name='send-test-email') -def send_test_email(manager): - """Sends test email to admins using Django 'sendtestemail' command""" - manager.send_test_email() diff --git a/mantis/extensions/nginx.py b/mantis/extensions/nginx.py index 3ce098a..01bfd6d 100644 --- a/mantis/extensions/nginx.py +++ b/mantis/extensions/nginx.py @@ -1,5 +1,4 @@ from mantis.helpers import CLI -from mantis.commands import command class Nginx(): @@ -10,15 +9,6 @@ def nginx_container(self): return self.get_container_name(self.nginx_service) def reload_webserver(self): - """ - Reloads nginx webserver - """ + """Reloads nginx webserver""" CLI.info('Reloading nginx...') self.docker(f'exec {self.nginx_container} nginx -s reload') - - -# Register extension commands -@command(name='reload-webserver') -def reload_webserver(manager): - """Reloads nginx webserver""" - manager.reload_webserver() diff --git a/mantis/extensions/postgres.py b/mantis/extensions/postgres.py index 0d4b7f4..42a1542 100644 --- a/mantis/extensions/postgres.py +++ b/mantis/extensions/postgres.py @@ -1,7 +1,6 @@ import datetime from mantis.helpers import CLI -from mantis.commands import command class Postgres(): @@ -12,19 +11,13 @@ def postgres_container(self): return self.get_container_name(self.postgres_service) def psql(self): - """ - Starts psql console - """ + """Starts psql console""" CLI.info('Starting psql...') env = self.env.load() self.docker(f'exec -it {self.postgres_container} psql -h {env["POSTGRES_HOST"]} -U {env["POSTGRES_USER"]} -d {env["POSTGRES_DBNAME"]} -W') - # https://blog.sleeplessbeastie.eu/2014/03/23/how-to-non-interactively-provide-password-for-the-postgresql-interactive-terminal/ - # TODO: https://www.postgresql.org/docs/9.1/libpq-pgpass.html def pg_dump(self, data_only=False, table=None): - """ - Backups PostgreSQL database [data and structure] - """ + """Backups PostgreSQL database""" if data_only: compressed = True data_only_param = '--data-only' @@ -39,24 +32,17 @@ def pg_dump(self, data_only=False, table=None): table_params = f'--table={table}' if table else '' now = datetime.datetime.now() - # filename = now.strftime("%Y%m%d%H%M%S") env = self.env.load() filename = now.strftime(f"{env['POSTGRES_DBNAME']}_%Y%m%d_%H%M{data_only_suffix}.{extension}") CLI.info(f'Backuping database into file {filename}') self.docker(f'exec -it {self.postgres_container} bash -c \'pg_dump {compressed_params} {data_only_param} -h {env["POSTGRES_HOST"]} -U {env["POSTGRES_USER"]} {table_params} {env["POSTGRES_DBNAME"]} -W > /backups/{filename}\'') - # https://blog.sleeplessbeastie.eu/2014/03/23/how-to-non-interactively-provide-password-for-the-postgresql-interactive-terminal/ - # TODO: https://www.postgresql.org/docs/9.1/libpq-pgpass.html def pg_dump_data(self, table=None): - """ - Backups PostgreSQL database [data only] - """ + """Backups PostgreSQL database (data only)""" self.pg_dump(data_only=True, table=table) def pg_restore(self, filename, table=None): - """ - Restores database from backup [data and structure] - """ + """Restores database from backup""" if table: CLI.info(f'Restoring table {table} from file {filename}') table_params = f'--table {table}' @@ -67,84 +53,7 @@ def pg_restore(self, filename, table=None): CLI.underline("Don't forget to drop database at first to prevent constraints collisions!") env = self.env.load() self.docker(f'exec -it {self.postgres_container} bash -c \'pg_restore -h {env["POSTGRES_HOST"]} -U {env["POSTGRES_USER"]} -d {env["POSTGRES_DBNAME"]} {table_params} -W < /backups/{filename}\'') - # print(f'exec -it {self.postgres_container} bash -c \'pg_restore -h {env["POSTGRES_HOST"]} -U {env["POSTGRES_USER"]} -d {env["POSTGRES_DBNAME"]} {table_params} -W < /backups/{filename}\'') - # https://blog.sleeplessbeastie.eu/2014/03/23/how-to-non-interactively-provide-password-for-the-postgresql-interactive-terminal/ - # TODO: https://www.postgresql.org/docs/9.1/libpq-pgpass.html - def pg_restore_data(self, params): - """ - Restores database from backup [data only] - """ - filename, table = params.split(',') + def pg_restore_data(self, filename, table): + """Restores database data from backup""" self.pg_restore(filename=filename, table=table) - - -# Register extension commands -@command() -def psql(manager): - """Starts psql console""" - manager.psql() - - -@command(name='pg-dump') -def pg_dump_cmd(manager, *args): - """Backups PostgreSQL database""" - data_only = '--data-only' in args or '-d' in args - table = None - for i, arg in enumerate(args): - if arg in ('--table', '-t') and i + 1 < len(args): - table = args[i + 1] - elif arg.startswith('--table='): - table = arg.split('=')[1] - manager.pg_dump(data_only=data_only, table=table) - - -@command(name='pg-dump-data') -def pg_dump_data_cmd(manager, *args): - """Backups PostgreSQL database [data only]""" - table = None - for i, arg in enumerate(args): - if arg in ('--table', '-t') and i + 1 < len(args): - table = args[i + 1] - elif arg.startswith('--table='): - table = arg.split('=')[1] - elif not arg.startswith('-'): - table = arg - manager.pg_dump_data(table=table) - - -@command(name='pg-restore') -def pg_restore_cmd(manager, *args): - """Restores database from backup""" - if not args: - CLI.error("pg-restore requires a filename argument") - filename = None - table = None - positional_args = [] - i = 0 - while i < len(args): - arg = args[i] - if arg in ('--table', '-t') and i + 1 < len(args): - table = args[i + 1] - i += 2 - elif arg.startswith('--table='): - table = arg.split('=')[1] - i += 1 - elif not arg.startswith('-'): - positional_args.append(arg) - i += 1 - else: - i += 1 - if positional_args: - filename = positional_args[0] - if not filename: - CLI.error("pg-restore requires a filename argument") - manager.pg_restore(filename=filename, table=table) - - -@command(name='pg-restore-data') -def pg_restore_data_cmd(manager, filename: str, table: str): - """Restores database from backup [data only]""" - if not filename or not table: - CLI.error("pg-restore-data requires filename and table arguments") - manager.pg_restore(filename=filename, table=table) From 52aca73e726fefa7746872ba0aeea52a538d2014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Fri, 2 Jan 2026 21:59:23 +0100 Subject: [PATCH 03/22] rich help panels --- mantis/command_line.py | 126 ++++++++++++++++++++++++++--------------- mantis/logic.py | 2 + 2 files changed, 81 insertions(+), 47 deletions(-) diff --git a/mantis/command_line.py b/mantis/command_line.py index 0165330..a248898 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -24,7 +24,39 @@ from mantis.helpers import CLI from mantis.logic import get_manager -app = typer.Typer(chain=True, rich_markup_mode="rich") +EPILOG = """\ +[bold]Examples:[/bold] + + mantis -e production status + + mantis -e production deploy --dirty + + mantis -e production build push deploy + + mantis -e prod manage migrate --fake + + mantis -e prod pg-dump --data-only --table users + + mantis -e prod bash web + + mantis -e prod logs django + + mantis status [dim](single connection mode)[/dim] + + + +[bold]Get help for a specific command:[/bold] + + mantis COMMAND --help +""" + +app = typer.Typer( + chain=True, + no_args_is_help=True, + rich_markup_mode="rich", + epilog=EPILOG, + context_settings={"max_content_width": 120}, +) # Commands that don't require environment NO_ENV_COMMANDS = {'generate-key', 'check-config', 'contexts', 'create-context', 'read-key'} @@ -121,7 +153,7 @@ def deploy( state.deploy(dirty=dirty) -@app.command() +@app.command(rich_help_panel="Images") def build( services: Optional[List[str]] = typer.Argument(None, help="Services to build"), ): @@ -129,7 +161,7 @@ def build( state.build(' '.join(services) if services else '') -@app.command() +@app.command(rich_help_panel="Images") def pull( services: Optional[List[str]] = typer.Argument(None, help="Services to pull"), ): @@ -137,7 +169,7 @@ def pull( state.pull(' '.join(services) if services else '') -@app.command() +@app.command(rich_help_panel="Images") def push( services: Optional[List[str]] = typer.Argument(None, help="Services to push"), ): @@ -145,7 +177,7 @@ def push( state.push(' '.join(services) if services else '') -@app.command() +@app.command(rich_help_panel="Files") def upload(): """Uploads config, compose and environment files to server""" state.upload() @@ -159,7 +191,7 @@ def clean( state.clean(' '.join(params) if params else '') -@app.command() +@app.command(rich_help_panel="Containers") def logs( container: Optional[str] = typer.Argument(None, help="Container name"), ): @@ -167,13 +199,13 @@ def logs( state.logs(container) -@app.command() +@app.command(rich_help_panel="Containers") def networks(): """Prints docker networks""" state.networks() -@app.command() +@app.command(rich_help_panel="Containers") def healthcheck( container: Optional[str] = typer.Argument(None, help="Container name"), ): @@ -181,7 +213,7 @@ def healthcheck( state.healthcheck(container) -@app.command() +@app.command(rich_help_panel="Compose") def up( params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), ): @@ -189,7 +221,7 @@ def up( state.up(' '.join(params) if params else '') -@app.command() +@app.command(rich_help_panel="Compose") def down( params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), ): @@ -197,7 +229,7 @@ def down( state.down(' '.join(params) if params else '') -@app.command() +@app.command(rich_help_panel="Services") def restart( service: Optional[str] = typer.Argument(None, help="Service to restart"), ): @@ -205,7 +237,7 @@ def restart( state.restart(service) -@app.command() +@app.command(rich_help_panel="Containers") def stop( containers: Optional[List[str]] = typer.Argument(None, help="Containers to stop"), ): @@ -213,7 +245,7 @@ def stop( state.stop(' '.join(containers) if containers else None) -@app.command() +@app.command(rich_help_panel="Containers") def start( containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), ): @@ -221,7 +253,7 @@ def start( state.start(' '.join(containers) if containers else '') -@app.command() +@app.command(rich_help_panel="Containers") def kill( containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), ): @@ -229,7 +261,7 @@ def kill( state.kill(' '.join(containers) if containers else None) -@app.command() +@app.command(rich_help_panel="Containers") def remove( containers: Optional[List[str]] = typer.Argument(None, help="Containers to remove"), ): @@ -237,7 +269,7 @@ def remove( state.remove(' '.join(containers) if containers else '') -@app.command("run") +@app.command("run", rich_help_panel="Compose") def run_cmd( params: List[str] = typer.Argument(..., help="Compose run parameters"), ): @@ -245,7 +277,7 @@ def run_cmd( state.run(' '.join(params)) -@app.command() +@app.command(rich_help_panel="Containers") def bash( container: str = typer.Argument(..., help="Container name"), ): @@ -253,7 +285,7 @@ def bash( state.bash(container) -@app.command() +@app.command(rich_help_panel="Containers") def sh( container: str = typer.Argument(..., help="Container name"), ): @@ -261,13 +293,13 @@ def sh( state.sh(container) -@app.command("ssh") +@app.command("ssh", rich_help_panel="Connections") def ssh_cmd(): """Connects to remote host via SSH""" state.ssh() -@app.command("exec") +@app.command("exec", rich_help_panel="Containers") def exec_cmd( container: str = typer.Argument(..., help="Container name"), command: List[str] = typer.Argument(..., help="Command to execute"), @@ -276,7 +308,7 @@ def exec_cmd( state.exec(f"{container} {' '.join(command)}") -@app.command("exec-it") +@app.command("exec-it", rich_help_panel="Containers") def exec_it( container: str = typer.Argument(..., help="Container name"), command: List[str] = typer.Argument(..., help="Command to execute"), @@ -285,7 +317,7 @@ def exec_it( state.exec_it(f"{container} {' '.join(command)}") -@app.command() +@app.command(rich_help_panel="Services") def scale( service: str = typer.Argument(..., help="Service name"), num: int = typer.Argument(..., help="Number of instances"), @@ -294,7 +326,7 @@ def scale( state.scale(service, num) -@app.command("zero-downtime") +@app.command("zero-downtime", rich_help_panel="Services") def zero_downtime( service: Optional[str] = typer.Argument(None, help="Service name"), ): @@ -302,7 +334,7 @@ def zero_downtime( state.zero_downtime(service) -@app.command("restart-service") +@app.command("restart-service", rich_help_panel="Services") def restart_service( service: str = typer.Argument(..., help="Service name"), ): @@ -310,7 +342,7 @@ def restart_service( state.restart_service(service) -@app.command("remove-suffixes") +@app.command("remove-suffixes", rich_help_panel="Containers") def remove_suffixes( prefix: str = typer.Argument("", help="Prefix to match"), ): @@ -322,7 +354,7 @@ def remove_suffixes( # Encryption Commands # ============================================================================= -@app.command("encrypt-env") +@app.command("encrypt-env", rich_help_panel="Cryptography") def encrypt_env( force: bool = typer.Option(False, "--force", help="Skip confirmation"), ): @@ -330,7 +362,7 @@ def encrypt_env( state.encrypt_env(params='force' if force else '') -@app.command("decrypt-env") +@app.command("decrypt-env", rich_help_panel="Cryptography") def decrypt_env( force: bool = typer.Option(False, "--force", help="Skip confirmation"), ): @@ -338,19 +370,19 @@ def decrypt_env( state.decrypt_env(params='force' if force else '') -@app.command("check-env") +@app.command("check-env", rich_help_panel="Cryptography") def check_env(): """Compares encrypted and decrypted env files""" state.check_env() -@app.command("generate-key") +@app.command("generate-key", rich_help_panel="Cryptography") def generate_key(): """Creates new encryption key""" state.generate_key() -@app.command("read-key") +@app.command("read-key", rich_help_panel="Cryptography") def read_key(): """Returns encryption key value""" print(state.read_key()) @@ -366,13 +398,13 @@ def check_config(): state.check_config() -@app.command() +@app.command(rich_help_panel="Connections") def contexts(): """Prints all docker contexts""" state.contexts() -@app.command("create-context") +@app.command("create-context", rich_help_panel="Connections") def create_context(): """Creates docker context""" state.create_context() @@ -382,21 +414,21 @@ def create_context(): # Service Info Commands # ============================================================================= -@app.command() +@app.command(rich_help_panel="Services") def services(): """Lists all defined services""" for service in state.services(): print(service) -@app.command("services-to-build") +@app.command("services-to-build", rich_help_panel="Services") def services_to_build(): """Lists services that will be built""" for service, info in state.services_to_build().items(): print(f"{service}: {info}") -@app.command("get-container-name") +@app.command("get-container-name", rich_help_panel="Containers") def get_container_name( service: str = typer.Argument(..., help="Service name"), ): @@ -404,7 +436,7 @@ def get_container_name( print(state.get_container_name(service)) -@app.command("get-image-name") +@app.command("get-image-name", rich_help_panel="Images") def get_image_name( service: str = typer.Argument(..., help="Service name"), ): @@ -416,7 +448,7 @@ def get_image_name( # Volume Commands # ============================================================================= -@app.command("backup-volume") +@app.command("backup-volume", rich_help_panel="Volumes") def backup_volume( volume: str = typer.Argument(..., help="Volume name"), ): @@ -424,7 +456,7 @@ def backup_volume( state.backup_volume(volume) -@app.command("restore-volume") +@app.command("restore-volume", rich_help_panel="Volumes") def restore_volume( volume: str = typer.Argument(..., help="Volume name"), file: str = typer.Argument(..., help="Backup file"), @@ -437,13 +469,13 @@ def restore_volume( # Django Extension Commands # ============================================================================= -@app.command() +@app.command(rich_help_panel="Django") def shell(): """Runs Django shell""" state.shell() -@app.command() +@app.command(rich_help_panel="Django") def manage( command: str = typer.Argument(..., help="Django management command"), args: Optional[List[str]] = typer.Argument(None, help="Command arguments"), @@ -453,7 +485,7 @@ def manage( state.manage(full_cmd) -@app.command("send-test-email") +@app.command("send-test-email", rich_help_panel="Django") def send_test_email(): """Sends test email to admins""" state.send_test_email() @@ -463,13 +495,13 @@ def send_test_email(): # PostgreSQL Extension Commands # ============================================================================= -@app.command() +@app.command(rich_help_panel="PostgreSQL") def psql(): """Starts psql console""" state.psql() -@app.command("pg-dump") +@app.command("pg-dump", rich_help_panel="PostgreSQL") def pg_dump( data_only: bool = typer.Option(False, "--data-only", "-d", help="Dump data only"), table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), @@ -478,7 +510,7 @@ def pg_dump( state.pg_dump(data_only=data_only, table=table) -@app.command("pg-dump-data") +@app.command("pg-dump-data", rich_help_panel="PostgreSQL") def pg_dump_data( table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), ): @@ -486,7 +518,7 @@ def pg_dump_data( state.pg_dump_data(table=table) -@app.command("pg-restore") +@app.command("pg-restore", rich_help_panel="PostgreSQL") def pg_restore( filename: str = typer.Argument(..., help="Backup filename"), table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), @@ -495,7 +527,7 @@ def pg_restore( state.pg_restore(filename=filename, table=table) -@app.command("pg-restore-data") +@app.command("pg-restore-data", rich_help_panel="PostgreSQL") def pg_restore_data( filename: str = typer.Argument(..., help="Backup filename"), table: str = typer.Argument(..., help="Table name"), @@ -508,7 +540,7 @@ def pg_restore_data( # Nginx Extension Commands # ============================================================================= -@app.command("reload-webserver") +@app.command("reload-webserver", rich_help_panel="Nginx") def reload_webserver(): """Reloads nginx webserver""" state.reload_webserver() diff --git a/mantis/logic.py b/mantis/logic.py index d84f3fd..ec3e112 100644 --- a/mantis/logic.py +++ b/mantis/logic.py @@ -142,6 +142,8 @@ def check_config(config): CLI.error( f"Config file validation failed. Unknown config keys: {config_keys_only}. Check {template_link} for available attributes.") + CLI.success(f"Config passed validation.") + exit() def get_extension_classes(extensions): extension_classes = [] From 7ef0cef82db472daa0475debcdbff8d37f92d021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Fri, 2 Jan 2026 22:09:18 +0100 Subject: [PATCH 04/22] shortcuts --- mantis/command_line.py | 33 ++++++++++++++++++++++++++++----- mantis/logic.py | 1 - 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/mantis/command_line.py b/mantis/command_line.py index a248898..2c46721 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -120,17 +120,24 @@ def __getattr__(self, name): state = State() +def add_shortcut(name: str, shortcut: str, func, panel: str = "Shortcuts"): + """Register a command shortcut.""" + app.command(shortcut, rich_help_panel=panel, help=f"Alias for '{name}'")(func) + + +def version_callback(value: bool): + if value: + typer.echo(f"Mantis v{VERSION}") + raise typer.Exit() + + @app.callback() def main( environment: Optional[str] = typer.Option(None, "--env", "-e", help="Environment ID"), mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"), - version: bool = typer.Option(False, "--version", "-v", help="Show version and exit"), + version: bool = typer.Option(False, "--version", "-v", callback=version_callback, is_eager=True, help="Show version and exit"), ): """Mantis CLI - Docker deployment tool.""" - if version: - typer.echo(f"Mantis v{VERSION}") - raise typer.Exit() - state._mode = mode state._manager = get_manager(environment, mode) @@ -546,6 +553,22 @@ def reload_webserver(): state.reload_webserver() +# ============================================================================= +# Command Shortcuts (hidden from help) +# ============================================================================= + +add_shortcut("status", "s", status) +add_shortcut("deploy", "d", deploy) +add_shortcut("build", "b", build) +add_shortcut("pull", "pl", pull) +add_shortcut("push", "p", push) +add_shortcut("upload", "u", upload) +add_shortcut("clean", "c", clean) +add_shortcut("logs", "l", logs) +add_shortcut("networks", "n", networks) +add_shortcut("healthcheck", "hc", healthcheck) + + def run(): """Entry point for the CLI.""" app() diff --git a/mantis/logic.py b/mantis/logic.py index ec3e112..0339e19 100644 --- a/mantis/logic.py +++ b/mantis/logic.py @@ -143,7 +143,6 @@ def check_config(config): f"Config file validation failed. Unknown config keys: {config_keys_only}. Check {template_link} for available attributes.") CLI.success(f"Config passed validation.") - exit() def get_extension_classes(extensions): extension_classes = [] From 579360bed66a52905eecbe238365ad9b0cf2e951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Fri, 2 Jan 2026 23:59:08 +0100 Subject: [PATCH 05/22] refactoring project structure --- mantis/__main__.py | 2 +- mantis/app.py | 129 ++++++++ mantis/command_line.py | 557 +-------------------------------- mantis/commands.py | 433 +++++++++++++++++++++++++ mantis/{logic.py => config.py} | 44 +-- mantis/managers.py | 47 ++- 6 files changed, 612 insertions(+), 600 deletions(-) create mode 100644 mantis/app.py create mode 100644 mantis/commands.py rename mantis/{logic.py => config.py} (77%) diff --git a/mantis/__main__.py b/mantis/__main__.py index 17bfe2b..9669c01 100644 --- a/mantis/__main__.py +++ b/mantis/__main__.py @@ -1,4 +1,4 @@ -from mantis.logic import main +from mantis.app import main if __name__ == "__main__": main() diff --git a/mantis/app.py b/mantis/app.py new file mode 100644 index 0000000..1212c39 --- /dev/null +++ b/mantis/app.py @@ -0,0 +1,129 @@ +"""Mantis CLI app setup and shared state.""" +import inspect +import os +from typing import Optional + +import typer +from rich.console import Console +from rich.text import Text + +from mantis import VERSION +from mantis.helpers import CLI +from mantis.managers import get_manager + +EPILOG = """\ +[bold]Examples:[/bold] + + mantis -e production status + + mantis -e production deploy --dirty + + mantis -e production build push deploy + + mantis -e prod manage migrate --fake + + mantis -e prod pg-dump --data-only --table users + + mantis -e prod bash web + + mantis -e prod logs django + + mantis status [dim](single connection mode)[/dim] + + + +[bold]Get help for a specific command:[/bold] + + mantis COMMAND --help +""" + +app = typer.Typer( + chain=True, + no_args_is_help=True, + rich_markup_mode="rich", + epilog=EPILOG, + context_settings={"max_content_width": 120}, +) + +# Commands that don't require environment +NO_ENV_COMMANDS = {'generate-key', 'check-config', 'contexts', 'create-context', 'read-key'} + + +class State: + """Shared state across commands.""" + def __init__(self): + self._manager = None + self._mode = 'remote' + self._heading_printed = False + + def _ensure_ready(self, command_name: str): + """Print heading and validate environment.""" + if not self._heading_printed: + print_heading(self._manager, self._mode) + self._heading_printed = True + + if command_name not in NO_ENV_COMMANDS: + if not self._manager.single_connection_mode and self._manager.environment_id is None: + CLI.error(f'Command "{command_name}" requires environment. Use: mantis -e {command_name}') + + def __getattr__(self, name): + """Delegate method calls to manager, handling heading and validation.""" + # Get the caller function name (the command) + caller = inspect.stack()[1].function + command_name = caller.replace('_', '-') + self._ensure_ready(command_name) + return getattr(self._manager, name) + + +state = State() + + +def print_heading(manager, mode: str): + """Print the heading with environment and connection info.""" + console = Console() + hostname = os.popen('hostname').read().rstrip("\n") + + heading = Text() + heading.append(f'Mantis v{VERSION}') + heading.append(", ") + + if manager.environment_id: + heading.append("Environment ID = ") + heading.append(str(manager.environment_id), style="bold") + heading.append(", ") + elif manager.single_connection_mode: + heading.append("(single connection mode)", style="bold") + heading.append(", ") + + if manager.connection and manager.host: + heading.append(str(manager.host), style="red") + heading.append(", ") + + heading.append("mode: ") + heading.append(str(mode), style="green") + heading.append(", hostname: ") + heading.append(hostname, style="blue") + + console.print(heading) + + +def add_shortcut(name: str, shortcut: str, func, panel: str = "Shortcuts"): + """Register a command shortcut.""" + app.command(shortcut, rich_help_panel=panel, help=f"Alias for '{name}'")(func) + + +def version_callback(value: bool): + if value: + typer.echo(f"Mantis v{VERSION}") + raise typer.Exit() + + +@app.callback() +def main( + environment: Optional[str] = typer.Option(None, "--env", "-e", help="Environment ID"), + mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"), + version: bool = typer.Option(False, "--version", "-v", callback=version_callback, is_eager=True, help="Show version and exit"), +): + """Mantis CLI - Docker deployment tool.""" + state._mode = mode + state._manager = get_manager(environment, mode) diff --git a/mantis/command_line.py b/mantis/command_line.py index 2c46721..d173410 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -12,561 +12,10 @@ mantis status (single connection mode) mantis manage migrate """ -import inspect -import os -from typing import Optional, List +from mantis.app import app -import typer -from rich.console import Console -from rich.text import Text - -from mantis import VERSION -from mantis.helpers import CLI -from mantis.logic import get_manager - -EPILOG = """\ -[bold]Examples:[/bold] - - mantis -e production status - - mantis -e production deploy --dirty - - mantis -e production build push deploy - - mantis -e prod manage migrate --fake - - mantis -e prod pg-dump --data-only --table users - - mantis -e prod bash web - - mantis -e prod logs django - - mantis status [dim](single connection mode)[/dim] - - - -[bold]Get help for a specific command:[/bold] - - mantis COMMAND --help -""" - -app = typer.Typer( - chain=True, - no_args_is_help=True, - rich_markup_mode="rich", - epilog=EPILOG, - context_settings={"max_content_width": 120}, -) - -# Commands that don't require environment -NO_ENV_COMMANDS = {'generate-key', 'check-config', 'contexts', 'create-context', 'read-key'} - - -def print_heading(manager, mode: str): - """Print the heading with environment and connection info.""" - console = Console() - hostname = os.popen('hostname').read().rstrip("\n") - - heading = Text() - heading.append(f'Mantis v{VERSION}') - heading.append(", ") - - if manager.environment_id: - heading.append("Environment ID = ") - heading.append(str(manager.environment_id), style="bold") - heading.append(", ") - elif manager.single_connection_mode: - heading.append("(single connection mode)", style="bold") - heading.append(", ") - - if manager.connection and manager.host: - heading.append(str(manager.host), style="red") - heading.append(", ") - - heading.append("mode: ") - heading.append(str(mode), style="green") - heading.append(", hostname: ") - heading.append(hostname, style="blue") - - console.print(heading) - - -class State: - """Shared state across commands.""" - def __init__(self): - self._manager = None - self._mode = 'remote' - self._heading_printed = False - - def _ensure_ready(self, command_name: str): - """Print heading and validate environment.""" - if not self._heading_printed: - print_heading(self._manager, self._mode) - self._heading_printed = True - - if command_name not in NO_ENV_COMMANDS: - if not self._manager.single_connection_mode and self._manager.environment_id is None: - CLI.error(f'Command "{command_name}" requires environment. Use: mantis -e {command_name}') - - def __getattr__(self, name): - """Delegate method calls to manager, handling heading and validation.""" - # Get the caller function name (the command) - caller = inspect.stack()[1].function - command_name = caller.replace('_', '-') - self._ensure_ready(command_name) - return getattr(self._manager, name) - - -state = State() - - -def add_shortcut(name: str, shortcut: str, func, panel: str = "Shortcuts"): - """Register a command shortcut.""" - app.command(shortcut, rich_help_panel=panel, help=f"Alias for '{name}'")(func) - - -def version_callback(value: bool): - if value: - typer.echo(f"Mantis v{VERSION}") - raise typer.Exit() - - -@app.callback() -def main( - environment: Optional[str] = typer.Option(None, "--env", "-e", help="Environment ID"), - mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"), - version: bool = typer.Option(False, "--version", "-v", callback=version_callback, is_eager=True, help="Show version and exit"), -): - """Mantis CLI - Docker deployment tool.""" - state._mode = mode - state._manager = get_manager(environment, mode) - - -# ============================================================================= -# Core Commands -# ============================================================================= - -@app.command() -def status(): - """Prints images and containers""" - state.status() - - -@app.command() -def deploy( - dirty: bool = typer.Option(False, "--dirty", help="Skip clean step"), -): - """Runs deployment process""" - state.deploy(dirty=dirty) - - -@app.command(rich_help_panel="Images") -def build( - services: Optional[List[str]] = typer.Argument(None, help="Services to build"), -): - """Builds all services with Dockerfiles""" - state.build(' '.join(services) if services else '') - - -@app.command(rich_help_panel="Images") -def pull( - services: Optional[List[str]] = typer.Argument(None, help="Services to pull"), -): - """Pulls required images for services""" - state.pull(' '.join(services) if services else '') - - -@app.command(rich_help_panel="Images") -def push( - services: Optional[List[str]] = typer.Argument(None, help="Services to push"), -): - """Push built images to repository""" - state.push(' '.join(services) if services else '') - - -@app.command(rich_help_panel="Files") -def upload(): - """Uploads config, compose and environment files to server""" - state.upload() - - -@app.command() -def clean( - params: Optional[List[str]] = typer.Argument(None, help="Clean parameters"), -): - """Clean images, containers, networks""" - state.clean(' '.join(params) if params else '') - - -@app.command(rich_help_panel="Containers") -def logs( - container: Optional[str] = typer.Argument(None, help="Container name"), -): - """Prints logs of containers""" - state.logs(container) - - -@app.command(rich_help_panel="Containers") -def networks(): - """Prints docker networks""" - state.networks() - - -@app.command(rich_help_panel="Containers") -def healthcheck( - container: Optional[str] = typer.Argument(None, help="Container name"), -): - """Execute health-check of container""" - state.healthcheck(container) - - -@app.command(rich_help_panel="Compose") -def up( - params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), -): - """Calls compose up""" - state.up(' '.join(params) if params else '') - - -@app.command(rich_help_panel="Compose") -def down( - params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), -): - """Calls compose down""" - state.down(' '.join(params) if params else '') - - -@app.command(rich_help_panel="Services") -def restart( - service: Optional[str] = typer.Argument(None, help="Service to restart"), -): - """Restarts containers""" - state.restart(service) - - -@app.command(rich_help_panel="Containers") -def stop( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to stop"), -): - """Stops containers""" - state.stop(' '.join(containers) if containers else None) - - -@app.command(rich_help_panel="Containers") -def start( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), -): - """Starts containers""" - state.start(' '.join(containers) if containers else '') - - -@app.command(rich_help_panel="Containers") -def kill( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), -): - """Kills containers""" - state.kill(' '.join(containers) if containers else None) - - -@app.command(rich_help_panel="Containers") -def remove( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to remove"), -): - """Removes containers""" - state.remove(' '.join(containers) if containers else '') - - -@app.command("run", rich_help_panel="Compose") -def run_cmd( - params: List[str] = typer.Argument(..., help="Compose run parameters"), -): - """Calls compose run with params""" - state.run(' '.join(params)) - - -@app.command(rich_help_panel="Containers") -def bash( - container: str = typer.Argument(..., help="Container name"), -): - """Runs bash in container""" - state.bash(container) - - -@app.command(rich_help_panel="Containers") -def sh( - container: str = typer.Argument(..., help="Container name"), -): - """Runs sh in container""" - state.sh(container) - - -@app.command("ssh", rich_help_panel="Connections") -def ssh_cmd(): - """Connects to remote host via SSH""" - state.ssh() - - -@app.command("exec", rich_help_panel="Containers") -def exec_cmd( - container: str = typer.Argument(..., help="Container name"), - command: List[str] = typer.Argument(..., help="Command to execute"), -): - """Executes command in container""" - state.exec(f"{container} {' '.join(command)}") - - -@app.command("exec-it", rich_help_panel="Containers") -def exec_it( - container: str = typer.Argument(..., help="Container name"), - command: List[str] = typer.Argument(..., help="Command to execute"), -): - """Executes command in container (interactive)""" - state.exec_it(f"{container} {' '.join(command)}") - - -@app.command(rich_help_panel="Services") -def scale( - service: str = typer.Argument(..., help="Service name"), - num: int = typer.Argument(..., help="Number of instances"), -): - """Scales service to given number""" - state.scale(service, num) - - -@app.command("zero-downtime", rich_help_panel="Services") -def zero_downtime( - service: Optional[str] = typer.Argument(None, help="Service name"), -): - """Runs zero-downtime deployment""" - state.zero_downtime(service) - - -@app.command("restart-service", rich_help_panel="Services") -def restart_service( - service: str = typer.Argument(..., help="Service name"), -): - """Restarts a specific service""" - state.restart_service(service) - - -@app.command("remove-suffixes", rich_help_panel="Containers") -def remove_suffixes( - prefix: str = typer.Argument("", help="Prefix to match"), -): - """Removes numerical suffixes from container names""" - state.remove_suffixes(prefix) - - -# ============================================================================= -# Encryption Commands -# ============================================================================= - -@app.command("encrypt-env", rich_help_panel="Cryptography") -def encrypt_env( - force: bool = typer.Option(False, "--force", help="Skip confirmation"), -): - """Encrypts environment files""" - state.encrypt_env(params='force' if force else '') - - -@app.command("decrypt-env", rich_help_panel="Cryptography") -def decrypt_env( - force: bool = typer.Option(False, "--force", help="Skip confirmation"), -): - """Decrypts environment files""" - state.decrypt_env(params='force' if force else '') - - -@app.command("check-env", rich_help_panel="Cryptography") -def check_env(): - """Compares encrypted and decrypted env files""" - state.check_env() - - -@app.command("generate-key", rich_help_panel="Cryptography") -def generate_key(): - """Creates new encryption key""" - state.generate_key() - - -@app.command("read-key", rich_help_panel="Cryptography") -def read_key(): - """Returns encryption key value""" - print(state.read_key()) - - -# ============================================================================= -# Config Commands -# ============================================================================= - -@app.command("check-config") -def check_config(): - """Validates config file""" - state.check_config() - - -@app.command(rich_help_panel="Connections") -def contexts(): - """Prints all docker contexts""" - state.contexts() - - -@app.command("create-context", rich_help_panel="Connections") -def create_context(): - """Creates docker context""" - state.create_context() - - -# ============================================================================= -# Service Info Commands -# ============================================================================= - -@app.command(rich_help_panel="Services") -def services(): - """Lists all defined services""" - for service in state.services(): - print(service) - - -@app.command("services-to-build", rich_help_panel="Services") -def services_to_build(): - """Lists services that will be built""" - for service, info in state.services_to_build().items(): - print(f"{service}: {info}") - - -@app.command("get-container-name", rich_help_panel="Containers") -def get_container_name( - service: str = typer.Argument(..., help="Service name"), -): - """Gets container name for service""" - print(state.get_container_name(service)) - - -@app.command("get-image-name", rich_help_panel="Images") -def get_image_name( - service: str = typer.Argument(..., help="Service name"), -): - """Gets image name for service""" - print(state.get_image_name(service)) - - -# ============================================================================= -# Volume Commands -# ============================================================================= - -@app.command("backup-volume", rich_help_panel="Volumes") -def backup_volume( - volume: str = typer.Argument(..., help="Volume name"), -): - """Backups volume to a file""" - state.backup_volume(volume) - - -@app.command("restore-volume", rich_help_panel="Volumes") -def restore_volume( - volume: str = typer.Argument(..., help="Volume name"), - file: str = typer.Argument(..., help="Backup file"), -): - """Restores volume from a file""" - state.restore_volume(volume, file) - - -# ============================================================================= -# Django Extension Commands -# ============================================================================= - -@app.command(rich_help_panel="Django") -def shell(): - """Runs Django shell""" - state.shell() - - -@app.command(rich_help_panel="Django") -def manage( - command: str = typer.Argument(..., help="Django management command"), - args: Optional[List[str]] = typer.Argument(None, help="Command arguments"), -): - """Runs Django manage command""" - full_cmd = command + (' ' + ' '.join(args) if args else '') - state.manage(full_cmd) - - -@app.command("send-test-email", rich_help_panel="Django") -def send_test_email(): - """Sends test email to admins""" - state.send_test_email() - - -# ============================================================================= -# PostgreSQL Extension Commands -# ============================================================================= - -@app.command(rich_help_panel="PostgreSQL") -def psql(): - """Starts psql console""" - state.psql() - - -@app.command("pg-dump", rich_help_panel="PostgreSQL") -def pg_dump( - data_only: bool = typer.Option(False, "--data-only", "-d", help="Dump data only"), - table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), -): - """Backups PostgreSQL database""" - state.pg_dump(data_only=data_only, table=table) - - -@app.command("pg-dump-data", rich_help_panel="PostgreSQL") -def pg_dump_data( - table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), -): - """Backups PostgreSQL database (data only)""" - state.pg_dump_data(table=table) - - -@app.command("pg-restore", rich_help_panel="PostgreSQL") -def pg_restore( - filename: str = typer.Argument(..., help="Backup filename"), - table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), -): - """Restores database from backup""" - state.pg_restore(filename=filename, table=table) - - -@app.command("pg-restore-data", rich_help_panel="PostgreSQL") -def pg_restore_data( - filename: str = typer.Argument(..., help="Backup filename"), - table: str = typer.Argument(..., help="Table name"), -): - """Restores database data from backup""" - state.pg_restore(filename=filename, table=table) - - -# ============================================================================= -# Nginx Extension Commands -# ============================================================================= - -@app.command("reload-webserver", rich_help_panel="Nginx") -def reload_webserver(): - """Reloads nginx webserver""" - state.reload_webserver() - - -# ============================================================================= -# Command Shortcuts (hidden from help) -# ============================================================================= - -add_shortcut("status", "s", status) -add_shortcut("deploy", "d", deploy) -add_shortcut("build", "b", build) -add_shortcut("pull", "pl", pull) -add_shortcut("push", "p", push) -add_shortcut("upload", "u", upload) -add_shortcut("clean", "c", clean) -add_shortcut("logs", "l", logs) -add_shortcut("networks", "n", networks) -add_shortcut("healthcheck", "hc", healthcheck) +# Import commands to register them with the app +from mantis import commands # noqa: F401 def run(): diff --git a/mantis/commands.py b/mantis/commands.py new file mode 100644 index 0000000..b31f34d --- /dev/null +++ b/mantis/commands.py @@ -0,0 +1,433 @@ +"""Mantis CLI command definitions.""" +from typing import Optional, List + +import typer + +from mantis.app import app, state, add_shortcut + + +# ============================================================================= +# Core Commands +# ============================================================================= + +@app.command() +def status(): + """Prints images and containers""" + state.status() + + +@app.command() +def deploy( + dirty: bool = typer.Option(False, "--dirty", help="Skip clean step"), +): + """Runs deployment process""" + state.deploy(dirty=dirty) + + +@app.command(rich_help_panel="Images") +def build( + services: Optional[List[str]] = typer.Argument(None, help="Services to build"), +): + """Builds all services with Dockerfiles""" + state.build(' '.join(services) if services else '') + + +@app.command(rich_help_panel="Images") +def pull( + services: Optional[List[str]] = typer.Argument(None, help="Services to pull"), +): + """Pulls required images for services""" + state.pull(' '.join(services) if services else '') + + +@app.command(rich_help_panel="Images") +def push( + services: Optional[List[str]] = typer.Argument(None, help="Services to push"), +): + """Push built images to repository""" + state.push(' '.join(services) if services else '') + + +@app.command(rich_help_panel="Files") +def upload(): + """Uploads config, compose and environment files to server""" + state.upload() + + +@app.command() +def clean( + params: Optional[List[str]] = typer.Argument(None, help="Clean parameters"), +): + """Clean images, containers, networks""" + state.clean(' '.join(params) if params else '') + + +@app.command(rich_help_panel="Containers") +def logs( + container: Optional[str] = typer.Argument(None, help="Container name"), +): + """Prints logs of containers""" + state.logs(container) + + +@app.command(rich_help_panel="Containers") +def networks(): + """Prints docker networks""" + state.networks() + + +@app.command(rich_help_panel="Containers") +def healthcheck( + container: Optional[str] = typer.Argument(None, help="Container name"), +): + """Execute health-check of container""" + state.healthcheck(container) + + +@app.command(rich_help_panel="Compose") +def up( + params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), +): + """Calls compose up""" + state.up(' '.join(params) if params else '') + + +@app.command(rich_help_panel="Compose") +def down( + params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), +): + """Calls compose down""" + state.down(' '.join(params) if params else '') + + +@app.command(rich_help_panel="Services") +def restart( + service: Optional[str] = typer.Argument(None, help="Service to restart"), +): + """Restarts containers""" + state.restart(service) + + +@app.command(rich_help_panel="Containers") +def stop( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to stop"), +): + """Stops containers""" + state.stop(' '.join(containers) if containers else None) + + +@app.command(rich_help_panel="Containers") +def start( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), +): + """Starts containers""" + state.start(' '.join(containers) if containers else '') + + +@app.command(rich_help_panel="Containers") +def kill( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), +): + """Kills containers""" + state.kill(' '.join(containers) if containers else None) + + +@app.command(rich_help_panel="Containers") +def remove( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to remove"), +): + """Removes containers""" + state.remove(' '.join(containers) if containers else '') + + +@app.command("run", rich_help_panel="Compose") +def run_cmd( + params: List[str] = typer.Argument(..., help="Compose run parameters"), +): + """Calls compose run with params""" + state.run(' '.join(params)) + + +@app.command(rich_help_panel="Containers") +def bash( + container: str = typer.Argument(..., help="Container name"), +): + """Runs bash in container""" + state.bash(container) + + +@app.command(rich_help_panel="Containers") +def sh( + container: str = typer.Argument(..., help="Container name"), +): + """Runs sh in container""" + state.sh(container) + + +@app.command("ssh", rich_help_panel="Connections") +def ssh_cmd(): + """Connects to remote host via SSH""" + state.ssh() + + +@app.command("exec", rich_help_panel="Containers") +def exec_cmd( + container: str = typer.Argument(..., help="Container name"), + command: List[str] = typer.Argument(..., help="Command to execute"), +): + """Executes command in container""" + state.exec(f"{container} {' '.join(command)}") + + +@app.command("exec-it", rich_help_panel="Containers") +def exec_it( + container: str = typer.Argument(..., help="Container name"), + command: List[str] = typer.Argument(..., help="Command to execute"), +): + """Executes command in container (interactive)""" + state.exec_it(f"{container} {' '.join(command)}") + + +@app.command(rich_help_panel="Services") +def scale( + service: str = typer.Argument(..., help="Service name"), + num: int = typer.Argument(..., help="Number of instances"), +): + """Scales service to given number""" + state.scale(service, num) + + +@app.command("zero-downtime", rich_help_panel="Services") +def zero_downtime( + service: Optional[str] = typer.Argument(None, help="Service name"), +): + """Runs zero-downtime deployment""" + state.zero_downtime(service) + + +@app.command("restart-service", rich_help_panel="Services") +def restart_service( + service: str = typer.Argument(..., help="Service name"), +): + """Restarts a specific service""" + state.restart_service(service) + + +@app.command("remove-suffixes", rich_help_panel="Containers") +def remove_suffixes( + prefix: str = typer.Argument("", help="Prefix to match"), +): + """Removes numerical suffixes from container names""" + state.remove_suffixes(prefix) + + +# ============================================================================= +# Encryption Commands +# ============================================================================= + +@app.command("encrypt-env", rich_help_panel="Cryptography") +def encrypt_env( + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Encrypts environment files""" + state.encrypt_env(params='force' if force else '') + + +@app.command("decrypt-env", rich_help_panel="Cryptography") +def decrypt_env( + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Decrypts environment files""" + state.decrypt_env(params='force' if force else '') + + +@app.command("check-env", rich_help_panel="Cryptography") +def check_env(): + """Compares encrypted and decrypted env files""" + state.check_env() + + +@app.command("generate-key", rich_help_panel="Cryptography") +def generate_key(): + """Creates new encryption key""" + state.generate_key() + + +@app.command("read-key", rich_help_panel="Cryptography") +def read_key(): + """Returns encryption key value""" + print(state.read_key()) + + +# ============================================================================= +# Config Commands +# ============================================================================= + +@app.command("check-config") +def check_config(): + """Validates config file""" + state.check_config() + + +@app.command(rich_help_panel="Connections") +def contexts(): + """Prints all docker contexts""" + state.contexts() + + +@app.command("create-context", rich_help_panel="Connections") +def create_context(): + """Creates docker context""" + state.create_context() + + +# ============================================================================= +# Service Info Commands +# ============================================================================= + +@app.command(rich_help_panel="Services") +def services(): + """Lists all defined services""" + for service in state.services(): + print(service) + + +@app.command("services-to-build", rich_help_panel="Services") +def services_to_build(): + """Lists services that will be built""" + for service, info in state.services_to_build().items(): + print(f"{service}: {info}") + + +@app.command("get-container-name", rich_help_panel="Containers") +def get_container_name( + service: str = typer.Argument(..., help="Service name"), +): + """Gets container name for service""" + print(state.get_container_name(service)) + + +@app.command("get-image-name", rich_help_panel="Images") +def get_image_name( + service: str = typer.Argument(..., help="Service name"), +): + """Gets image name for service""" + print(state.get_image_name(service)) + + +# ============================================================================= +# Volume Commands +# ============================================================================= + +@app.command("backup-volume", rich_help_panel="Volumes") +def backup_volume( + volume: str = typer.Argument(..., help="Volume name"), +): + """Backups volume to a file""" + state.backup_volume(volume) + + +@app.command("restore-volume", rich_help_panel="Volumes") +def restore_volume( + volume: str = typer.Argument(..., help="Volume name"), + file: str = typer.Argument(..., help="Backup file"), +): + """Restores volume from a file""" + state.restore_volume(volume, file) + + +# ============================================================================= +# Django Extension Commands +# ============================================================================= + +@app.command(rich_help_panel="Django") +def shell(): + """Runs Django shell""" + state.shell() + + +@app.command(rich_help_panel="Django") +def manage( + command: str = typer.Argument(..., help="Django management command"), + args: Optional[List[str]] = typer.Argument(None, help="Command arguments"), +): + """Runs Django manage command""" + full_cmd = command + (' ' + ' '.join(args) if args else '') + state.manage(full_cmd) + + +@app.command("send-test-email", rich_help_panel="Django") +def send_test_email(): + """Sends test email to admins""" + state.send_test_email() + + +# ============================================================================= +# PostgreSQL Extension Commands +# ============================================================================= + +@app.command(rich_help_panel="PostgreSQL") +def psql(): + """Starts psql console""" + state.psql() + + +@app.command("pg-dump", rich_help_panel="PostgreSQL") +def pg_dump( + data_only: bool = typer.Option(False, "--data-only", "-d", help="Dump data only"), + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Backups PostgreSQL database""" + state.pg_dump(data_only=data_only, table=table) + + +@app.command("pg-dump-data", rich_help_panel="PostgreSQL") +def pg_dump_data( + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Backups PostgreSQL database (data only)""" + state.pg_dump_data(table=table) + + +@app.command("pg-restore", rich_help_panel="PostgreSQL") +def pg_restore( + filename: str = typer.Argument(..., help="Backup filename"), + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Restores database from backup""" + state.pg_restore(filename=filename, table=table) + + +@app.command("pg-restore-data", rich_help_panel="PostgreSQL") +def pg_restore_data( + filename: str = typer.Argument(..., help="Backup filename"), + table: str = typer.Argument(..., help="Table name"), +): + """Restores database data from backup""" + state.pg_restore(filename=filename, table=table) + + +# ============================================================================= +# Nginx Extension Commands +# ============================================================================= + +@app.command("reload-webserver", rich_help_panel="Nginx") +def reload_webserver(): + """Reloads nginx webserver""" + state.reload_webserver() + + +# ============================================================================= +# Command Shortcuts +# ============================================================================= + +add_shortcut("status", "s", status) +add_shortcut("deploy", "d", deploy) +add_shortcut("build", "b", build) +add_shortcut("pull", "pl", pull) +add_shortcut("push", "p", push) +add_shortcut("upload", "u", upload) +add_shortcut("clean", "c", clean) +add_shortcut("logs", "l", logs) +add_shortcut("networks", "n", networks) +add_shortcut("healthcheck", "hc", healthcheck) diff --git a/mantis/logic.py b/mantis/config.py similarity index 77% rename from mantis/logic.py rename to mantis/config.py index 0339e19..64aae40 100644 --- a/mantis/logic.py +++ b/mantis/config.py @@ -6,7 +6,7 @@ from rich.console import Console from rich.table import Table -from mantis.helpers import CLI, import_string +from mantis.helpers import CLI def find_config(environment_id=None): @@ -143,45 +143,3 @@ def check_config(config): f"Config file validation failed. Unknown config keys: {config_keys_only}. Check {template_link} for available attributes.") CLI.success(f"Config passed validation.") - -def get_extension_classes(extensions): - extension_classes = [] - - # extensions - for extension in extensions: - extension_class_name = extension if '.' in extension else f"mantis.extensions.{extension.lower()}.{extension}" - extension_class = import_string(extension_class_name) - extension_classes.append(extension_class) - - return extension_classes - - -def get_manager(environment_id, mode): - # config file - config_file = find_config(environment_id) - config = load_config(config_file) - - # class name of the manager - manager_class_name = config.get('manager_class', 'mantis.managers.BaseManager') - - # get manager class - manager_class = import_string(manager_class_name) - - # setup extensions - extensions = config.get('extensions', {}) - extension_classes = get_extension_classes(extensions.keys()) - - CLI.info(f"Extensions: {', '.join(extensions.keys())}") - - # create dynamic manager class - class MantisManager(*[manager_class] + extension_classes): - pass - - manager = MantisManager(config_file=config_file, environment_id=environment_id, mode=mode) - - # set extensions data - for extension, extension_params in extensions.items(): - if 'service' in extension_params: - setattr(manager, f'{extension}_service'.lower(), extension_params['service']) - - return manager diff --git a/mantis/managers.py b/mantis/managers.py index 7a0ff45..4f82ccd 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -13,8 +13,8 @@ from mantis.crypto import Crypto from mantis.environment import Environment -from mantis.helpers import CLI, merge_json -from mantis.logic import find_config, load_config, check_config, load_template_config +from mantis.helpers import CLI, import_string, merge_json +from mantis.config import find_config, load_config, check_config, load_template_config class AbstractManager(object): @@ -1390,3 +1390,46 @@ def restore_volume(self, volume, file): tar -xzvf /backup/{file}' self.docker(command) + + +def get_extension_classes(extensions): + extension_classes = [] + + # extensions + for extension in extensions: + extension_class_name = extension if '.' in extension else f"mantis.extensions.{extension.lower()}.{extension}" + extension_class = import_string(extension_class_name) + extension_classes.append(extension_class) + + return extension_classes + + +def get_manager(environment_id, mode): + # config file + config_file = find_config(environment_id) + config = load_config(config_file) + + # class name of the manager + manager_class_name = config.get('manager_class', 'mantis.managers.BaseManager') + + # get manager class + manager_class = import_string(manager_class_name) + + # setup extensions + extensions = config.get('extensions', {}) + extension_classes = get_extension_classes(extensions.keys()) + + CLI.info(f"Extensions: {', '.join(extensions.keys())}") + + # create dynamic manager class + class MantisManager(*[manager_class] + extension_classes): + pass + + manager = MantisManager(config_file=config_file, environment_id=environment_id, mode=mode) + + # set extensions data + for extension, extension_params in extensions.items(): + if 'service' in extension_params: + setattr(manager, f'{extension}_service'.lower(), extension_params['service']) + + return manager From 611304bf59abd7df42f4f81e3960594255dcccf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 00:13:59 +0100 Subject: [PATCH 06/22] commands split into subfiles, custom command decorator, refactoring code --- mantis/app.py | 82 +++++-- mantis/commands.py | 433 ---------------------------------- mantis/commands/__init__.py | 28 +++ mantis/commands/compose.py | 30 +++ mantis/commands/config.py | 26 ++ mantis/commands/containers.py | 110 +++++++++ mantis/commands/core.py | 34 +++ mantis/commands/crypto.py | 38 +++ mantis/commands/django.py | 28 +++ mantis/commands/images.py | 38 +++ mantis/commands/nginx.py | 8 + mantis/commands/postgres.py | 47 ++++ mantis/commands/services.py | 53 +++++ mantis/commands/volumes.py | 21 ++ 14 files changed, 527 insertions(+), 449 deletions(-) delete mode 100644 mantis/commands.py create mode 100644 mantis/commands/__init__.py create mode 100644 mantis/commands/compose.py create mode 100644 mantis/commands/config.py create mode 100644 mantis/commands/containers.py create mode 100644 mantis/commands/core.py create mode 100644 mantis/commands/crypto.py create mode 100644 mantis/commands/django.py create mode 100644 mantis/commands/images.py create mode 100644 mantis/commands/nginx.py create mode 100644 mantis/commands/postgres.py create mode 100644 mantis/commands/services.py create mode 100644 mantis/commands/volumes.py diff --git a/mantis/app.py b/mantis/app.py index 1212c39..edf8522 100644 --- a/mantis/app.py +++ b/mantis/app.py @@ -1,7 +1,7 @@ """Mantis CLI app setup and shared state.""" -import inspect -import os -from typing import Optional +import socket +from functools import wraps +from typing import Optional, List, Callable import typer from rich.console import Console @@ -45,33 +45,41 @@ context_settings={"max_content_width": 120}, ) -# Commands that don't require environment -NO_ENV_COMMANDS = {'generate-key', 'check-config', 'contexts', 'create-context', 'read-key'} +# Commands that don't require environment (populated by @no_env_required decorator) +NO_ENV_COMMANDS: set[str] = set() + +# Cache hostname +_hostname = socket.gethostname() + + +def join_args(args: Optional[List[str]], separator: str = ' ') -> str: + """Join optional list of arguments into a string.""" + return separator.join(args) if args else '' class State: """Shared state across commands.""" + def __init__(self): self._manager = None self._mode = 'remote' self._heading_printed = False + self._current_command = None - def _ensure_ready(self, command_name: str): + def _ensure_ready(self): """Print heading and validate environment.""" if not self._heading_printed: print_heading(self._manager, self._mode) self._heading_printed = True - if command_name not in NO_ENV_COMMANDS: + command_name = self._current_command + if command_name and command_name not in NO_ENV_COMMANDS: if not self._manager.single_connection_mode and self._manager.environment_id is None: CLI.error(f'Command "{command_name}" requires environment. Use: mantis -e {command_name}') def __getattr__(self, name): """Delegate method calls to manager, handling heading and validation.""" - # Get the caller function name (the command) - caller = inspect.stack()[1].function - command_name = caller.replace('_', '-') - self._ensure_ready(command_name) + self._ensure_ready() return getattr(self._manager, name) @@ -81,7 +89,6 @@ def __getattr__(self, name): def print_heading(manager, mode: str): """Print the heading with environment and connection info.""" console = Console() - hostname = os.popen('hostname').read().rstrip("\n") heading = Text() heading.append(f'Mantis v{VERSION}') @@ -102,14 +109,57 @@ def print_heading(manager, mode: str): heading.append("mode: ") heading.append(str(mode), style="green") heading.append(", hostname: ") - heading.append(hostname, style="blue") + heading.append(_hostname, style="blue") console.print(heading) -def add_shortcut(name: str, shortcut: str, func, panel: str = "Shortcuts"): - """Register a command shortcut.""" - app.command(shortcut, rich_help_panel=panel, help=f"Alias for '{name}'")(func) +# ============================================================================= +# Decorators +# ============================================================================= + +def command( + name: str = None, + shortcut: str = None, + panel: str = None, + no_env: bool = False, +): + """ + Enhanced command decorator with shortcut and no_env support. + + Args: + name: Command name (defaults to function name with underscores replaced by dashes) + shortcut: Short alias for the command + panel: Rich help panel name + no_env: If True, command doesn't require environment + """ + def decorator(func: Callable) -> Callable: + cmd_name = name or func.__name__.replace('_', '-') + + # Mark as no-env command + if no_env: + NO_ENV_COMMANDS.add(cmd_name) + if shortcut: + NO_ENV_COMMANDS.add(shortcut) + + @wraps(func) + def wrapper(*args, **kwargs): + state._current_command = cmd_name + return func(*args, **kwargs) + + # Register main command + kwargs = {} + if panel: + kwargs['rich_help_panel'] = panel + registered = app.command(cmd_name, **kwargs)(wrapper) + + # Register shortcut + if shortcut: + app.command(shortcut, rich_help_panel="Shortcuts", help=f"Alias for '{cmd_name}'")(wrapper) + + return registered + + return decorator def version_callback(value: bool): diff --git a/mantis/commands.py b/mantis/commands.py deleted file mode 100644 index b31f34d..0000000 --- a/mantis/commands.py +++ /dev/null @@ -1,433 +0,0 @@ -"""Mantis CLI command definitions.""" -from typing import Optional, List - -import typer - -from mantis.app import app, state, add_shortcut - - -# ============================================================================= -# Core Commands -# ============================================================================= - -@app.command() -def status(): - """Prints images and containers""" - state.status() - - -@app.command() -def deploy( - dirty: bool = typer.Option(False, "--dirty", help="Skip clean step"), -): - """Runs deployment process""" - state.deploy(dirty=dirty) - - -@app.command(rich_help_panel="Images") -def build( - services: Optional[List[str]] = typer.Argument(None, help="Services to build"), -): - """Builds all services with Dockerfiles""" - state.build(' '.join(services) if services else '') - - -@app.command(rich_help_panel="Images") -def pull( - services: Optional[List[str]] = typer.Argument(None, help="Services to pull"), -): - """Pulls required images for services""" - state.pull(' '.join(services) if services else '') - - -@app.command(rich_help_panel="Images") -def push( - services: Optional[List[str]] = typer.Argument(None, help="Services to push"), -): - """Push built images to repository""" - state.push(' '.join(services) if services else '') - - -@app.command(rich_help_panel="Files") -def upload(): - """Uploads config, compose and environment files to server""" - state.upload() - - -@app.command() -def clean( - params: Optional[List[str]] = typer.Argument(None, help="Clean parameters"), -): - """Clean images, containers, networks""" - state.clean(' '.join(params) if params else '') - - -@app.command(rich_help_panel="Containers") -def logs( - container: Optional[str] = typer.Argument(None, help="Container name"), -): - """Prints logs of containers""" - state.logs(container) - - -@app.command(rich_help_panel="Containers") -def networks(): - """Prints docker networks""" - state.networks() - - -@app.command(rich_help_panel="Containers") -def healthcheck( - container: Optional[str] = typer.Argument(None, help="Container name"), -): - """Execute health-check of container""" - state.healthcheck(container) - - -@app.command(rich_help_panel="Compose") -def up( - params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), -): - """Calls compose up""" - state.up(' '.join(params) if params else '') - - -@app.command(rich_help_panel="Compose") -def down( - params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), -): - """Calls compose down""" - state.down(' '.join(params) if params else '') - - -@app.command(rich_help_panel="Services") -def restart( - service: Optional[str] = typer.Argument(None, help="Service to restart"), -): - """Restarts containers""" - state.restart(service) - - -@app.command(rich_help_panel="Containers") -def stop( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to stop"), -): - """Stops containers""" - state.stop(' '.join(containers) if containers else None) - - -@app.command(rich_help_panel="Containers") -def start( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), -): - """Starts containers""" - state.start(' '.join(containers) if containers else '') - - -@app.command(rich_help_panel="Containers") -def kill( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), -): - """Kills containers""" - state.kill(' '.join(containers) if containers else None) - - -@app.command(rich_help_panel="Containers") -def remove( - containers: Optional[List[str]] = typer.Argument(None, help="Containers to remove"), -): - """Removes containers""" - state.remove(' '.join(containers) if containers else '') - - -@app.command("run", rich_help_panel="Compose") -def run_cmd( - params: List[str] = typer.Argument(..., help="Compose run parameters"), -): - """Calls compose run with params""" - state.run(' '.join(params)) - - -@app.command(rich_help_panel="Containers") -def bash( - container: str = typer.Argument(..., help="Container name"), -): - """Runs bash in container""" - state.bash(container) - - -@app.command(rich_help_panel="Containers") -def sh( - container: str = typer.Argument(..., help="Container name"), -): - """Runs sh in container""" - state.sh(container) - - -@app.command("ssh", rich_help_panel="Connections") -def ssh_cmd(): - """Connects to remote host via SSH""" - state.ssh() - - -@app.command("exec", rich_help_panel="Containers") -def exec_cmd( - container: str = typer.Argument(..., help="Container name"), - command: List[str] = typer.Argument(..., help="Command to execute"), -): - """Executes command in container""" - state.exec(f"{container} {' '.join(command)}") - - -@app.command("exec-it", rich_help_panel="Containers") -def exec_it( - container: str = typer.Argument(..., help="Container name"), - command: List[str] = typer.Argument(..., help="Command to execute"), -): - """Executes command in container (interactive)""" - state.exec_it(f"{container} {' '.join(command)}") - - -@app.command(rich_help_panel="Services") -def scale( - service: str = typer.Argument(..., help="Service name"), - num: int = typer.Argument(..., help="Number of instances"), -): - """Scales service to given number""" - state.scale(service, num) - - -@app.command("zero-downtime", rich_help_panel="Services") -def zero_downtime( - service: Optional[str] = typer.Argument(None, help="Service name"), -): - """Runs zero-downtime deployment""" - state.zero_downtime(service) - - -@app.command("restart-service", rich_help_panel="Services") -def restart_service( - service: str = typer.Argument(..., help="Service name"), -): - """Restarts a specific service""" - state.restart_service(service) - - -@app.command("remove-suffixes", rich_help_panel="Containers") -def remove_suffixes( - prefix: str = typer.Argument("", help="Prefix to match"), -): - """Removes numerical suffixes from container names""" - state.remove_suffixes(prefix) - - -# ============================================================================= -# Encryption Commands -# ============================================================================= - -@app.command("encrypt-env", rich_help_panel="Cryptography") -def encrypt_env( - force: bool = typer.Option(False, "--force", help="Skip confirmation"), -): - """Encrypts environment files""" - state.encrypt_env(params='force' if force else '') - - -@app.command("decrypt-env", rich_help_panel="Cryptography") -def decrypt_env( - force: bool = typer.Option(False, "--force", help="Skip confirmation"), -): - """Decrypts environment files""" - state.decrypt_env(params='force' if force else '') - - -@app.command("check-env", rich_help_panel="Cryptography") -def check_env(): - """Compares encrypted and decrypted env files""" - state.check_env() - - -@app.command("generate-key", rich_help_panel="Cryptography") -def generate_key(): - """Creates new encryption key""" - state.generate_key() - - -@app.command("read-key", rich_help_panel="Cryptography") -def read_key(): - """Returns encryption key value""" - print(state.read_key()) - - -# ============================================================================= -# Config Commands -# ============================================================================= - -@app.command("check-config") -def check_config(): - """Validates config file""" - state.check_config() - - -@app.command(rich_help_panel="Connections") -def contexts(): - """Prints all docker contexts""" - state.contexts() - - -@app.command("create-context", rich_help_panel="Connections") -def create_context(): - """Creates docker context""" - state.create_context() - - -# ============================================================================= -# Service Info Commands -# ============================================================================= - -@app.command(rich_help_panel="Services") -def services(): - """Lists all defined services""" - for service in state.services(): - print(service) - - -@app.command("services-to-build", rich_help_panel="Services") -def services_to_build(): - """Lists services that will be built""" - for service, info in state.services_to_build().items(): - print(f"{service}: {info}") - - -@app.command("get-container-name", rich_help_panel="Containers") -def get_container_name( - service: str = typer.Argument(..., help="Service name"), -): - """Gets container name for service""" - print(state.get_container_name(service)) - - -@app.command("get-image-name", rich_help_panel="Images") -def get_image_name( - service: str = typer.Argument(..., help="Service name"), -): - """Gets image name for service""" - print(state.get_image_name(service)) - - -# ============================================================================= -# Volume Commands -# ============================================================================= - -@app.command("backup-volume", rich_help_panel="Volumes") -def backup_volume( - volume: str = typer.Argument(..., help="Volume name"), -): - """Backups volume to a file""" - state.backup_volume(volume) - - -@app.command("restore-volume", rich_help_panel="Volumes") -def restore_volume( - volume: str = typer.Argument(..., help="Volume name"), - file: str = typer.Argument(..., help="Backup file"), -): - """Restores volume from a file""" - state.restore_volume(volume, file) - - -# ============================================================================= -# Django Extension Commands -# ============================================================================= - -@app.command(rich_help_panel="Django") -def shell(): - """Runs Django shell""" - state.shell() - - -@app.command(rich_help_panel="Django") -def manage( - command: str = typer.Argument(..., help="Django management command"), - args: Optional[List[str]] = typer.Argument(None, help="Command arguments"), -): - """Runs Django manage command""" - full_cmd = command + (' ' + ' '.join(args) if args else '') - state.manage(full_cmd) - - -@app.command("send-test-email", rich_help_panel="Django") -def send_test_email(): - """Sends test email to admins""" - state.send_test_email() - - -# ============================================================================= -# PostgreSQL Extension Commands -# ============================================================================= - -@app.command(rich_help_panel="PostgreSQL") -def psql(): - """Starts psql console""" - state.psql() - - -@app.command("pg-dump", rich_help_panel="PostgreSQL") -def pg_dump( - data_only: bool = typer.Option(False, "--data-only", "-d", help="Dump data only"), - table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), -): - """Backups PostgreSQL database""" - state.pg_dump(data_only=data_only, table=table) - - -@app.command("pg-dump-data", rich_help_panel="PostgreSQL") -def pg_dump_data( - table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), -): - """Backups PostgreSQL database (data only)""" - state.pg_dump_data(table=table) - - -@app.command("pg-restore", rich_help_panel="PostgreSQL") -def pg_restore( - filename: str = typer.Argument(..., help="Backup filename"), - table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), -): - """Restores database from backup""" - state.pg_restore(filename=filename, table=table) - - -@app.command("pg-restore-data", rich_help_panel="PostgreSQL") -def pg_restore_data( - filename: str = typer.Argument(..., help="Backup filename"), - table: str = typer.Argument(..., help="Table name"), -): - """Restores database data from backup""" - state.pg_restore(filename=filename, table=table) - - -# ============================================================================= -# Nginx Extension Commands -# ============================================================================= - -@app.command("reload-webserver", rich_help_panel="Nginx") -def reload_webserver(): - """Reloads nginx webserver""" - state.reload_webserver() - - -# ============================================================================= -# Command Shortcuts -# ============================================================================= - -add_shortcut("status", "s", status) -add_shortcut("deploy", "d", deploy) -add_shortcut("build", "b", build) -add_shortcut("pull", "pl", pull) -add_shortcut("push", "p", push) -add_shortcut("upload", "u", upload) -add_shortcut("clean", "c", clean) -add_shortcut("logs", "l", logs) -add_shortcut("networks", "n", networks) -add_shortcut("healthcheck", "hc", healthcheck) diff --git a/mantis/commands/__init__.py b/mantis/commands/__init__.py new file mode 100644 index 0000000..2a58760 --- /dev/null +++ b/mantis/commands/__init__.py @@ -0,0 +1,28 @@ +"""Mantis CLI command modules.""" +from mantis.commands import ( + core, + images, + containers, + compose, + services, + crypto, + config, + volumes, + django, + postgres, + nginx, +) + +__all__ = [ + 'core', + 'images', + 'containers', + 'compose', + 'services', + 'crypto', + 'config', + 'volumes', + 'django', + 'postgres', + 'nginx', +] diff --git a/mantis/commands/compose.py b/mantis/commands/compose.py new file mode 100644 index 0000000..e48e5ce --- /dev/null +++ b/mantis/commands/compose.py @@ -0,0 +1,30 @@ +"""Compose commands: up, down, run.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state, join_args + + +@command(panel="Compose") +def up( + params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), +): + """Calls compose up""" + state.up(join_args(params)) + + +@command(panel="Compose") +def down( + params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), +): + """Calls compose down""" + state.down(join_args(params)) + + +@command(name="run", panel="Compose") +def run_cmd( + params: List[str] = typer.Argument(..., help="Compose run parameters"), +): + """Calls compose run with params""" + state.run(join_args(params)) diff --git a/mantis/commands/config.py b/mantis/commands/config.py new file mode 100644 index 0000000..b35d889 --- /dev/null +++ b/mantis/commands/config.py @@ -0,0 +1,26 @@ +"""Config commands: check-config, contexts, create-context, ssh.""" +from mantis.app import command, state + + +@command(name="check-config", no_env=True) +def check_config(): + """Validates config file""" + state.check_config() + + +@command(panel="Connections", no_env=True) +def contexts(): + """Prints all docker contexts""" + state.contexts() + + +@command(name="create-context", panel="Connections", no_env=True) +def create_context(): + """Creates docker context""" + state.create_context() + + +@command(name="ssh", panel="Connections") +def ssh_cmd(): + """Connects to remote host via SSH""" + state.ssh() diff --git a/mantis/commands/containers.py b/mantis/commands/containers.py new file mode 100644 index 0000000..ca331e1 --- /dev/null +++ b/mantis/commands/containers.py @@ -0,0 +1,110 @@ +"""Container commands: logs, start, stop, kill, remove, bash, sh, exec, etc.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state, join_args + + +@command(shortcut="l", panel="Containers") +def logs( + container: Optional[str] = typer.Argument(None, help="Container name"), +): + """Prints logs of containers""" + state.logs(container) + + +@command(shortcut="n", panel="Containers") +def networks(): + """Prints docker networks""" + state.networks() + + +@command(shortcut="hc", panel="Containers") +def healthcheck( + container: Optional[str] = typer.Argument(None, help="Container name"), +): + """Execute health-check of container""" + state.healthcheck(container) + + +@command(panel="Containers") +def stop( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to stop"), +): + """Stops containers""" + state.stop(join_args(containers) or None) + + +@command(panel="Containers") +def start( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), +): + """Starts containers""" + state.start(join_args(containers)) + + +@command(panel="Containers") +def kill( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), +): + """Kills containers""" + state.kill(join_args(containers) or None) + + +@command(panel="Containers") +def remove( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to remove"), +): + """Removes containers""" + state.remove(join_args(containers)) + + +@command(panel="Containers") +def bash( + container: str = typer.Argument(..., help="Container name"), +): + """Runs bash in container""" + state.bash(container) + + +@command(panel="Containers") +def sh( + container: str = typer.Argument(..., help="Container name"), +): + """Runs sh in container""" + state.sh(container) + + +@command(name="exec", panel="Containers") +def exec_cmd( + container: str = typer.Argument(..., help="Container name"), + cmd: List[str] = typer.Argument(..., help="Command to execute"), +): + """Executes command in container""" + state.exec(f"{container} {join_args(cmd)}") + + +@command(name="exec-it", panel="Containers") +def exec_it( + container: str = typer.Argument(..., help="Container name"), + cmd: List[str] = typer.Argument(..., help="Command to execute"), +): + """Executes command in container (interactive)""" + state.exec_it(f"{container} {join_args(cmd)}") + + +@command(name="get-container-name", panel="Containers") +def get_container_name( + service: str = typer.Argument(..., help="Service name"), +): + """Gets container name for service""" + print(state.get_container_name(service)) + + +@command(name="remove-suffixes", panel="Containers") +def remove_suffixes( + prefix: str = typer.Argument("", help="Prefix to match"), +): + """Removes numerical suffixes from container names""" + state.remove_suffixes(prefix) diff --git a/mantis/commands/core.py b/mantis/commands/core.py new file mode 100644 index 0000000..a0ef879 --- /dev/null +++ b/mantis/commands/core.py @@ -0,0 +1,34 @@ +"""Core commands: status, deploy, clean, upload.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state, join_args + + +@command(shortcut="s") +def status(): + """Prints images and containers""" + state.status() + + +@command(shortcut="d") +def deploy( + dirty: bool = typer.Option(False, "--dirty", help="Skip clean step"), +): + """Runs deployment process""" + state.deploy(dirty=dirty) + + +@command(shortcut="c") +def clean( + params: Optional[List[str]] = typer.Argument(None, help="Clean parameters"), +): + """Clean images, containers, networks""" + state.clean(join_args(params)) + + +@command(shortcut="u", panel="Files") +def upload(): + """Uploads config, compose and environment files to server""" + state.upload() diff --git a/mantis/commands/crypto.py b/mantis/commands/crypto.py new file mode 100644 index 0000000..265dd8a --- /dev/null +++ b/mantis/commands/crypto.py @@ -0,0 +1,38 @@ +"""Cryptography commands: encrypt-env, decrypt-env, check-env, generate-key, read-key.""" +import typer + +from mantis.app import command, state + + +@command(name="encrypt-env", panel="Cryptography") +def encrypt_env( + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Encrypts environment files""" + state.encrypt_env(params='force' if force else '') + + +@command(name="decrypt-env", panel="Cryptography") +def decrypt_env( + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Decrypts environment files""" + state.decrypt_env(params='force' if force else '') + + +@command(name="check-env", panel="Cryptography") +def check_env(): + """Compares encrypted and decrypted env files""" + state.check_env() + + +@command(name="generate-key", panel="Cryptography", no_env=True) +def generate_key(): + """Creates new encryption key""" + state.generate_key() + + +@command(name="read-key", panel="Cryptography", no_env=True) +def read_key(): + """Returns encryption key value""" + print(state.read_key()) diff --git a/mantis/commands/django.py b/mantis/commands/django.py new file mode 100644 index 0000000..51f9331 --- /dev/null +++ b/mantis/commands/django.py @@ -0,0 +1,28 @@ +"""Django extension commands: shell, manage, send-test-email.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state, join_args + + +@command(panel="Django") +def shell(): + """Runs Django shell""" + state.shell() + + +@command(panel="Django") +def manage( + cmd: str = typer.Argument(..., help="Django management command"), + args: Optional[List[str]] = typer.Argument(None, help="Command arguments"), +): + """Runs Django manage command""" + full_cmd = cmd + (' ' + join_args(args) if args else '') + state.manage(full_cmd) + + +@command(name="send-test-email", panel="Django") +def send_test_email(): + """Sends test email to admins""" + state.send_test_email() diff --git a/mantis/commands/images.py b/mantis/commands/images.py new file mode 100644 index 0000000..947ee8d --- /dev/null +++ b/mantis/commands/images.py @@ -0,0 +1,38 @@ +"""Image commands: build, pull, push, get-image-name.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state, join_args + + +@command(shortcut="b", panel="Images") +def build( + services: Optional[List[str]] = typer.Argument(None, help="Services to build"), +): + """Builds all services with Dockerfiles""" + state.build(join_args(services)) + + +@command(shortcut="pl", panel="Images") +def pull( + services: Optional[List[str]] = typer.Argument(None, help="Services to pull"), +): + """Pulls required images for services""" + state.pull(join_args(services)) + + +@command(shortcut="p", panel="Images") +def push( + services: Optional[List[str]] = typer.Argument(None, help="Services to push"), +): + """Push built images to repository""" + state.push(join_args(services)) + + +@command(name="get-image-name", panel="Images") +def get_image_name( + service: str = typer.Argument(..., help="Service name"), +): + """Gets image name for service""" + print(state.get_image_name(service)) diff --git a/mantis/commands/nginx.py b/mantis/commands/nginx.py new file mode 100644 index 0000000..6918ee6 --- /dev/null +++ b/mantis/commands/nginx.py @@ -0,0 +1,8 @@ +"""Nginx extension commands: reload-webserver.""" +from mantis.app import command, state + + +@command(name="reload-webserver", panel="Nginx") +def reload_webserver(): + """Reloads nginx webserver""" + state.reload_webserver() diff --git a/mantis/commands/postgres.py b/mantis/commands/postgres.py new file mode 100644 index 0000000..ff2a532 --- /dev/null +++ b/mantis/commands/postgres.py @@ -0,0 +1,47 @@ +"""PostgreSQL extension commands: psql, pg-dump, pg-dump-data, pg-restore, pg-restore-data.""" +from typing import Optional + +import typer + +from mantis.app import command, state + + +@command(panel="PostgreSQL") +def psql(): + """Starts psql console""" + state.psql() + + +@command(name="pg-dump", panel="PostgreSQL") +def pg_dump( + data_only: bool = typer.Option(False, "--data-only", "-d", help="Dump data only"), + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Backups PostgreSQL database""" + state.pg_dump(data_only=data_only, table=table) + + +@command(name="pg-dump-data", panel="PostgreSQL") +def pg_dump_data( + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Backups PostgreSQL database (data only)""" + state.pg_dump_data(table=table) + + +@command(name="pg-restore", panel="PostgreSQL") +def pg_restore( + filename: str = typer.Argument(..., help="Backup filename"), + table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table"), +): + """Restores database from backup""" + state.pg_restore(filename=filename, table=table) + + +@command(name="pg-restore-data", panel="PostgreSQL") +def pg_restore_data( + filename: str = typer.Argument(..., help="Backup filename"), + table: str = typer.Argument(..., help="Table name"), +): + """Restores database data from backup""" + state.pg_restore(filename=filename, table=table) diff --git a/mantis/commands/services.py b/mantis/commands/services.py new file mode 100644 index 0000000..7ccd0b3 --- /dev/null +++ b/mantis/commands/services.py @@ -0,0 +1,53 @@ +"""Service commands: restart, scale, zero-downtime, services, etc.""" +from typing import Optional + +import typer + +from mantis.app import command, state + + +@command(panel="Services") +def restart( + service: Optional[str] = typer.Argument(None, help="Service to restart"), +): + """Restarts containers""" + state.restart(service) + + +@command(panel="Services") +def scale( + service: str = typer.Argument(..., help="Service name"), + num: int = typer.Argument(..., help="Number of instances"), +): + """Scales service to given number""" + state.scale(service, num) + + +@command(name="zero-downtime", panel="Services") +def zero_downtime( + service: Optional[str] = typer.Argument(None, help="Service name"), +): + """Runs zero-downtime deployment""" + state.zero_downtime(service) + + +@command(name="restart-service", panel="Services") +def restart_service( + service: str = typer.Argument(..., help="Service name"), +): + """Restarts a specific service""" + state.restart_service(service) + + +@command(panel="Services") +def services(): + """Lists all defined services""" + for service in state.services(): + print(service) + + +@command(name="services-to-build", panel="Services") +def services_to_build(): + """Lists services that will be built""" + for service, info in state.services_to_build().items(): + print(f"{service}: {info}") diff --git a/mantis/commands/volumes.py b/mantis/commands/volumes.py new file mode 100644 index 0000000..1cb6154 --- /dev/null +++ b/mantis/commands/volumes.py @@ -0,0 +1,21 @@ +"""Volume commands: backup-volume, restore-volume.""" +import typer + +from mantis.app import command, state + + +@command(name="backup-volume", panel="Volumes") +def backup_volume( + volume: str = typer.Argument(..., help="Volume name"), +): + """Backups volume to a file""" + state.backup_volume(volume) + + +@command(name="restore-volume", panel="Volumes") +def restore_volume( + volume: str = typer.Argument(..., help="Volume name"), + file: str = typer.Argument(..., help="Backup file"), +): + """Restores volume from a file""" + state.restore_volume(volume, file) From 0c24f7f4f03f896aaa8d80e859406e937dbedbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 00:20:20 +0100 Subject: [PATCH 07/22] environment shortcut resolution --- mantis/managers.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/mantis/managers.py b/mantis/managers.py index 4f82ccd..6502540 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -1404,11 +1404,48 @@ def get_extension_classes(extensions): return extension_classes +def resolve_environment(environment_id, config): + """ + Resolves environment prefix to full environment ID. + + If the prefix matches exactly one environment, returns that environment ID. + If multiple environments match, raises an error with the ambiguous options. + If no environments match, raises an error with available options. + """ + if not environment_id: + return None + + # Single connection mode - no environment resolution needed + if config.get('connection'): + return environment_id + + connections = config.get('connections', {}) + available_envs = list(connections.keys()) + + # Check for exact match first + if environment_id in available_envs: + return environment_id + + # Find all environments that start with the prefix + matches = [env for env in available_envs if env.startswith(environment_id)] + + if len(matches) == 1: + CLI.info(f'Environment "{environment_id}" resolved to "{matches[0]}"') + return matches[0] + elif len(matches) > 1: + CLI.error(f'Ambiguous environment prefix "{environment_id}". Matches: {", ".join(sorted(matches))}') + else: + CLI.error(f'Environment "{environment_id}" not found. Available: {", ".join(sorted(available_envs))}') + + def get_manager(environment_id, mode): # config file config_file = find_config(environment_id) config = load_config(config_file) + # Resolve environment prefix to full ID + environment_id = resolve_environment(environment_id, config) + # class name of the manager manager_class_name = config.get('manager_class', 'mantis.managers.BaseManager') From 3c4104f2927c892765dc504969c9f7f040ea669b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 00:30:57 +0100 Subject: [PATCH 08/22] auto-select config --- mantis/config.py | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/mantis/config.py b/mantis/config.py index 64aae40..cf02712 100644 --- a/mantis/config.py +++ b/mantis/config.py @@ -45,6 +45,11 @@ def find_config(environment_id=None): table.add_column("Path") table.add_column("Connections") + # Track which configs have matching environments and single connection configs + matching_configs = [] + single_connection_configs = [] + all_environments = set() + for index, path in enumerate(paths): config = load_config(path) @@ -54,21 +59,48 @@ def find_config(environment_id=None): if single_connection: # Single connection mode - display the connection string connections_display = '[green](single)[/green]' + single_connection_configs.append((index, path)) else: # Multi-environment mode - display connection keys - connections = config.get('connections', {}).keys() - - # TODO: get project names from compose files + connections = list(config.get('connections', {}).keys()) + all_environments.update(connections) + # Check if any connection matches the environment prefix + has_match = False colorful_connections = [] for connection in connections: - color = 'green' if connection == environment_id else 'yellow' + # Highlight in green if exact match or prefix match + matches = environment_id and (connection == environment_id or connection.startswith(environment_id)) + if matches: + has_match = True + color = 'green' if matches else 'yellow' colorful_connections.append(f'[{color}]{connection}[/{color}]') connections_display = ', '.join(colorful_connections) + if has_match: + matching_configs.append((index, path)) + table.add_row(str(index + 1), normpath(dirname(path)), connections_display) + # Always print the table when multiple configs found console.print(table) + + # If environment was provided but no config has a matching environment, error out + if environment_id and not matching_configs: + CLI.error(f'Environment "{environment_id}" not found in any config. Available: {", ".join(sorted(all_environments))}') + + # If exactly one config has matching environment, auto-select it + if environment_id and len(matching_configs) == 1: + selected_path = matching_configs[0][1] + CLI.info(f'Auto-selected config: {normpath(dirname(selected_path))}') + return selected_path + + # If no environment provided and only one single connection config exists, auto-select it + if not environment_id and len(single_connection_configs) == 1: + selected_path = single_connection_configs[0][1] + CLI.info(f'Auto-selected single connection config: {normpath(dirname(selected_path))}') + return selected_path + CLI.danger(f'[0] Exit now and define $MANTIS_CONFIG environment variable') path_index = None From bf5dc00754dd7f1c9ed27ff858452dd81a53c1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 00:33:50 +0100 Subject: [PATCH 09/22] dimmed mantis configs without environment match --- mantis/config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mantis/config.py b/mantis/config.py index cf02712..6983640 100644 --- a/mantis/config.py +++ b/mantis/config.py @@ -55,18 +55,20 @@ def find_config(environment_id=None): # Check for single connection mode single_connection = config.get('connection') + has_match = False if single_connection: # Single connection mode - display the connection string connections_display = '[green](single)[/green]' single_connection_configs.append((index, path)) + # Single connection matches when no environment is specified + has_match = not environment_id else: # Multi-environment mode - display connection keys connections = list(config.get('connections', {}).keys()) all_environments.update(connections) # Check if any connection matches the environment prefix - has_match = False colorful_connections = [] for connection in connections: # Highlight in green if exact match or prefix match @@ -80,7 +82,9 @@ def find_config(environment_id=None): if has_match: matching_configs.append((index, path)) - table.add_row(str(index + 1), normpath(dirname(path)), connections_display) + # Dim path if no environment match + path_display = normpath(dirname(path)) if has_match else f'[dim]{normpath(dirname(path))}[/dim]' + table.add_row(str(index + 1), path_display, connections_display) # Always print the table when multiple configs found console.print(table) From 2ed0c422a48b4069f8dd888e73ad06b1a8be2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 01:03:47 +0100 Subject: [PATCH 10/22] using pathlib and subprocess --- mantis/config.py | 38 +++++++++++++++------------ mantis/helpers.py | 4 ++- mantis/managers.py | 65 +++++++++++++++++++++++----------------------- 3 files changed, 56 insertions(+), 51 deletions(-) diff --git a/mantis/config.py b/mantis/config.py index 6983640..389fec5 100644 --- a/mantis/config.py +++ b/mantis/config.py @@ -1,7 +1,8 @@ import os +import sys import json from json.decoder import JSONDecodeError -from os.path import dirname, normpath, abspath +from pathlib import Path from rich.console import Console from rich.table import Table @@ -9,6 +10,11 @@ from mantis.helpers import CLI +def get_config_dir(config_path: str) -> str: + """Get normalized directory path for a config file.""" + return str(Path(config_path).parent) + + def find_config(environment_id=None): env_path = os.environ.get('MANTIS_CONFIG', None) @@ -17,10 +23,10 @@ def find_config(environment_id=None): return env_path CLI.info('Environment variable $MANTIS_CONFIG not found. Looking for file mantis.json...') - paths = os.popen('find . -name mantis.json').read().strip().split('\n') + paths = [str(p) for p in Path('.').rglob('mantis.json')] - # Remove empty strings - paths = list(filter(None, paths)) + # Sort for consistent ordering + paths.sort() # Count found mantis files total_mantis_files = len(paths) @@ -83,7 +89,8 @@ def find_config(environment_id=None): matching_configs.append((index, path)) # Dim path if no environment match - path_display = normpath(dirname(path)) if has_match else f'[dim]{normpath(dirname(path))}[/dim]' + config_dir = get_config_dir(path) + path_display = config_dir if has_match else f'[dim]{config_dir}[/dim]' table.add_row(str(index + 1), path_display, connections_display) # Always print the table when multiple configs found @@ -96,13 +103,13 @@ def find_config(environment_id=None): # If exactly one config has matching environment, auto-select it if environment_id and len(matching_configs) == 1: selected_path = matching_configs[0][1] - CLI.info(f'Auto-selected config: {normpath(dirname(selected_path))}') + CLI.info(f'Auto-selected config: {get_config_dir(selected_path)}') return selected_path # If no environment provided and only one single connection config exists, auto-select it if not environment_id and len(single_connection_configs) == 1: selected_path = single_connection_configs[0][1] - CLI.info(f'Auto-selected single connection config: {normpath(dirname(selected_path))}') + CLI.info(f'Auto-selected single connection config: {get_config_dir(selected_path)}') return selected_path CLI.danger(f'[0] Exit now and define $MANTIS_CONFIG environment variable') @@ -116,7 +123,7 @@ def find_config(environment_id=None): path_index = int(path_index) if path_index == 0: - exit() + sys.exit(0) return paths[path_index - 1] @@ -141,13 +148,11 @@ def find_keys_only_in_config(config, template, parent_key=""): return differences -def load_config(config_file): - if not os.path.exists(config_file): +def load_config(config_file: str) -> dict: + if not Path(config_file).exists(): CLI.warning(f'File {config_file} does not exist.') CLI.danger(f'Mantis config not found. Double check your current working directory.') - exit() - # CLI.warning(f'File {config_file} does not exist. Returning empty config') - # return {} + sys.exit(1) with open(config_file, "r") as config: try: @@ -156,10 +161,9 @@ def load_config(config_file): CLI.error(f"Failed to load config from file {config_file}: {e}") -def load_template_config(): - current_directory = dirname(abspath(__file__)) - template_path = normpath(f'{current_directory}/mantis.tpl') - return load_config(template_path) +def load_template_config() -> dict: + template_path = Path(__file__).parent / 'mantis.tpl' + return load_config(str(template_path)) def check_config(config): diff --git a/mantis/helpers.py b/mantis/helpers.py index 1d78ed5..0b466fe 100644 --- a/mantis/helpers.py +++ b/mantis/helpers.py @@ -1,3 +1,5 @@ +import sys + from rich.console import Console from rich.text import Text @@ -15,7 +17,7 @@ def _print(text, style, end='\n'): def error(text): styled_text = Text(str(text), style='red') _console.print(styled_text) - exit(1) + sys.exit(1) @staticmethod def bold(text, end='\n'): diff --git a/mantis/managers.py b/mantis/managers.py index 6502540..b8c016a 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -1,11 +1,12 @@ import json import os +import subprocess +import sys import time import yaml from collections import defaultdict from datetime import datetime -from os import path -from os.path import normpath +from pathlib import Path from time import sleep from rich.console import Console @@ -92,13 +93,15 @@ def connection_details(self): elif self.connection.startswith('context://'): context_name = self.connection.replace('context://', '') - # TODO: move to own method - context_details = json.loads(os.popen(f'docker context inspect {context_name}').read()) - + result = subprocess.run( + ['docker', 'context', 'inspect', context_name], + capture_output=True, text=True + ) try: + context_details = json.loads(result.stdout) ssh_host = context_details[0]["Endpoints"]["docker"]["Host"] details = self.parse_ssh_connection(ssh_host) - except IndexError: + except (json.JSONDecodeError, IndexError, KeyError): pass else: raise CLI.error(f'Invalid connection protocol {self.connection}') @@ -131,10 +134,10 @@ def docker_connection(self): def init_config(self, config): check_config(config) - config_file_path = path.normpath(path.join(self.config_file, os.pardir)) + config_file_path = str(Path(self.config_file).parent) - def normalize(path): - return os.path.normpath(path.replace('', config_file_path)) + def normalize(p): + return str(Path(p.replace('', config_file_path)).resolve()) # Load config template file defaults = load_template_config() @@ -159,14 +162,14 @@ def normalize(path): if self.single_connection_mode and self.environment_id: CLI.error(f'Config error: Environment "{self.environment_id}" was provided, but config uses single connection mode. Remove the environment argument or switch to named environments using "connections".') - self.key_file = normalize(path.join(self.config['encryption']['folder'], 'mantis.key')) + self.key_file = normalize(str(Path(self.config['encryption']['folder']) / 'mantis.key')) self.environment_path = normalize(self.config['environment']['folder']) if self.single_connection_mode: # In single connection mode, compose files are directly in compose folder self.compose_path = normalize(self.config['compose']['folder']) elif self.environment_id: - self.compose_path = normalize(path.join(self.config['compose']['folder'], self.environment_id)) + self.compose_path = normalize(str(Path(self.config['compose']['folder']) / self.environment_id)) def init_environment(self): if self.single_connection_mode: @@ -180,11 +183,10 @@ def init_environment(self): # connection from single 'connection' key self.connection = self.config.get('connection') - # compose files directly in compose folder - compose_file_paths = os.popen(f'find {self.compose_path} -maxdepth 1 -name "*.yml" -o -name "*.yaml"').read().strip().split('\n') - - # Remove empty strings - self.compose_files = list(filter(None, compose_file_paths)) + # compose files directly in compose folder (non-recursive) + compose_dir = Path(self.compose_path) + self.compose_files = [str(p) for p in compose_dir.glob('*.yml')] + \ + [str(p) for p in compose_dir.glob('*.yaml')] # Read compose files self.compose_config = self.read_compose_configs() @@ -206,11 +208,10 @@ def init_environment(self): # connection self.connection = self.config['connections'].get(self.env.id, None) - # compose files - compose_file_paths = os.popen(f'find {self.compose_path} -name "*.yml" -o -name "*.yaml"').read().strip().split('\n') - - # Remove empty strings - self.compose_files = list(filter(None, compose_file_paths)) + # compose files (recursive) + compose_dir = Path(self.compose_path) + self.compose_files = [str(p) for p in compose_dir.rglob('*.yml')] + \ + [str(p) for p in compose_dir.rglob('*.yaml')] # Read compose files self.compose_config = self.read_compose_configs() @@ -293,12 +294,11 @@ def cmd(self, command): try: print(command) - if os.system(command) != 0: + result = subprocess.run(command, shell=True) + if result.returncode != 0: CLI.error(error_message) - # raise Exception(error_message) - except: - CLI.error(error_message) - # raise Exception(error_message) + except OSError as e: + CLI.error(f"{error_message}: {e}") def docker_command(self, command, return_output=False, use_connection=True): docker_connection = self.docker_connection if use_connection else '' @@ -306,7 +306,8 @@ def docker_command(self, command, return_output=False, use_connection=True): cmd = f'{docker_connection} {command}' if return_output: - return os.popen(cmd).read() + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + return result.stdout self.cmd(cmd) @@ -590,7 +591,6 @@ def create_context(self): host = f'{protocol}://{username}@{host_address}:{port}' else: CLI.error('Invalid protocol') - exit() endpoint = f'host={host}' @@ -608,7 +608,6 @@ def create_context(self): if input("Confirm? (Y)es/(N)o: ").lower() != 'y': CLI.error('Canceled') - exit() # create context self.cmd(command) @@ -736,7 +735,7 @@ def healthcheck(self, container=None): CLI.warning(f'Stopping and removing container {container}') self.docker(f'container stop {container}') self.docker(f'container rm {container}') - exit() + sys.exit(1) # If container doesn't have healthcheck command, sleep for N seconds CLI.info(f'Sleeping for {start_period} seconds...') @@ -775,8 +774,8 @@ def build(self, params=''): image = info['image'] if info['image'] != '' else f"{info['project_name']}-{service}".lstrip('-') # build paths for docker build command (paths in compose are relative to compose file, but paths for docker command are relative to $PWD) - context = normpath(path.join(self.compose_path, info['context'])) - dockerfile = normpath(path.join(context, info['dockerfile'])) + context = str(Path(self.compose_path) / info['context']) + dockerfile = str(Path(context) / info['dockerfile']) # Build service using docker self.docker(f"build {context} {build_args} {args} {platform} {cache_from} -t {image} -f {dockerfile} {params}", @@ -1302,7 +1301,7 @@ def ssh(self): CLI.error('Unknown host') CLI.info(f'Executing SSH connection: {self.connection}') - os.system(f'ssh {self.user}@{self.host} -p {self.port or 22}') + subprocess.run(['ssh', f'{self.user}@{self.host}', '-p', str(self.port or 22)]) def exec(self, params): """ From 0a4913b3254b9afa1e595ef2f9303183641d5c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 01:11:11 +0100 Subject: [PATCH 11/22] Replace remaining os.path and os.getcwd with pathlib - Replace os.path.exists() with Path().exists() in managers.py - Replace os.getcwd() with Path.cwd() in managers.py - Rewrite environment.py to use pathlib throughout - Remove os module import from environment.py --- mantis/environment.py | 42 +++++++++++++++++++++++------------------- mantis/managers.py | 16 ++++++++-------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/mantis/environment.py b/mantis/environment.py index 86094c1..16c8b87 100644 --- a/mantis/environment.py +++ b/mantis/environment.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from mantis.helpers import CLI @@ -21,17 +21,19 @@ def setup(self): if not self.path: return - if not os.path.exists(self.path): + env_path = Path(self.path) + + if not env_path.exists(): CLI.error(f"Environment path '{self.path}' does not exist") - if not os.path.isdir(self.path): + if not env_path.is_dir(): CLI.error(f"Environment path '{self.path}' is not directory") - for dirpath, directories, files in os.walk(self.path): - environment_filenames = list(filter(lambda f: f.endswith('.env'), files)) - encrypted_environment_filenames = list(filter(lambda f: f.endswith('.env.encrypted'), files)) - self.files = list(map(lambda x: os.path.join(dirpath, x), environment_filenames)) - self.encrypted_files = list(map(lambda x: os.path.join(dirpath, x), encrypted_environment_filenames)) + for dirpath, directories, files in env_path.walk(): + environment_filenames = [f for f in files if f.endswith('.env')] + encrypted_environment_filenames = [f for f in files if f.endswith('.env.encrypted')] + self.files = [str(dirpath / f) for f in environment_filenames] + self.encrypted_files = [str(dirpath / f) for f in encrypted_environment_filenames] def setup_single_mode(self): """ @@ -39,32 +41,34 @@ def setup_single_mode(self): instead of environment subfolders """ self.path = self.folder + env_path = Path(self.path) - if not os.path.exists(self.path): + if not env_path.exists(): CLI.warning(f"Environment path '{self.path}' does not exist") self.files = [] self.encrypted_files = [] return - if not os.path.isdir(self.path): + if not env_path.is_dir(): CLI.error(f"Environment path '{self.path}' is not directory") CLI.info(f"Found environment path (single mode): '{self.path}'") # Look for env files directly in the folder (not in subdirectories) - files = os.listdir(self.path) - environment_filenames = list(filter(lambda f: f.endswith('.env') and not f.endswith('.encrypted'), files)) - encrypted_environment_filenames = list(filter(lambda f: f.endswith('.env.encrypted'), files)) - self.files = list(map(lambda x: os.path.join(self.path, x), environment_filenames)) - self.encrypted_files = list(map(lambda x: os.path.join(self.path, x), encrypted_environment_filenames)) + files = [f.name for f in env_path.iterdir() if f.is_file()] + environment_filenames = [f for f in files if f.endswith('.env') and not f.endswith('.encrypted')] + encrypted_environment_filenames = [f for f in files if f.endswith('.env.encrypted')] + self.files = [str(env_path / f) for f in environment_filenames] + self.encrypted_files = [str(env_path / f) for f in encrypted_environment_filenames] def _get_path(self, id): possible_folder_names = [f'.{id}', id] - possible_folders = list(map(lambda x: os.path.normpath(os.path.join(self.folder, x)), possible_folder_names)) + possible_folders = [str(Path(self.folder) / name) for name in possible_folder_names] for environment_path in possible_folders: - if os.path.exists(environment_path): - if not os.path.isdir(environment_path): + env_path = Path(environment_path) + if env_path.exists(): + if not env_path.is_dir(): CLI.error(f"Environment path '{environment_path}' is not directory") CLI.info(f"Found environment path: '{environment_path}'") @@ -73,7 +77,7 @@ def _get_path(self, id): CLI.danger(f"Environment path not found. Tried: {', '.join(possible_folders)}") def read(self, path): - if not os.path.exists(path): + if not Path(path).exists(): CLI.error(f'Environment file {path} does not exist') return None diff --git a/mantis/managers.py b/mantis/managers.py index b8c016a..2f0d26a 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -224,9 +224,9 @@ def are_env_files_in_sync(self, env_file): env_file_encrypted = f'{env_file}.encrypted' # Check if both files exist - if not os.path.exists(env_file): + if not Path(env_file).exists(): return False - if not os.path.exists(env_file_encrypted): + if not Path(env_file_encrypted).exists(): return False try: @@ -383,7 +383,7 @@ def read_key(self): """ Returns value of mantis encryption key """ - if not os.path.exists(self.key_file): + if not Path(self.key_file).exists(): CLI.warning(f'File {self.key_file} does not exist. Reading key from $MANTIS_KEY...') return os.environ.get('MANTIS_KEY', None) @@ -549,7 +549,7 @@ def check_env(self): # check if pair file exists for encrypted_env_file in self.env.encrypted_files: env_file = encrypted_env_file.rstrip('.encrypted') - if not os.path.exists(env_file): + if not Path(env_file).exists(): CLI.warning(f'Environment file {env_file} does not exist') if not hasattr(self.env, 'files'): @@ -559,7 +559,7 @@ def check_env(self): env_file_encrypted = f'{env_file}.encrypted' # check if pair file exists - if not os.path.exists(env_file_encrypted): + if not Path(env_file_encrypted).exists(): CLI.warning(f'Environment file {env_file_encrypted} does not exist') continue @@ -890,7 +890,7 @@ def upload(self): # mantis config file for file in files_to_upload: - if os.path.exists(file): + if Path(file).exists(): self.cmd(f'rsync -arvz -e \'ssh -p {self.port}\' -rvzh --progress {file} {self.user}@{self.host}:{self.project_path}/{file}') else: CLI.info(f'{self.config_file} does not exists. Skipping...') @@ -1363,7 +1363,7 @@ def get_deploy_replicas(self, service): def backup_volume(self, volume): # backups folder - backup_path = os.getcwd() + '/backups/' + backup_path = str(Path.cwd() / 'backups') # Get current date, time and timezone name current_datetime = datetime.now() @@ -1380,7 +1380,7 @@ def backup_volume(self, volume): def restore_volume(self, volume, file): # backups folder - backup_path = os.getcwd() + '/backups/' + backup_path = str(Path.cwd() / 'backups') command = f'run --rm \ -v {volume}:/{volume} \ From 4aadebd170939d6fae1595539a68dca2c5844f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 09:29:37 +0100 Subject: [PATCH 12/22] pydantic config validation, parallel build, dry run --- mantis/app.py | 10 +++++- mantis/config.py | 37 +++++++++++-------- mantis/environment.py | 7 ++-- mantis/helpers.py | 22 ++++++++++++ mantis/managers.py | 71 ++++++++++++++++++++++++++++++++---- mantis/schema.py | 83 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 2 +- 7 files changed, 205 insertions(+), 27 deletions(-) create mode 100644 mantis/schema.py diff --git a/mantis/app.py b/mantis/app.py index edf8522..a451d02 100644 --- a/mantis/app.py +++ b/mantis/app.py @@ -43,6 +43,7 @@ rich_markup_mode="rich", epilog=EPILOG, context_settings={"max_content_width": 120}, + add_completion=True, ) # Commands that don't require environment (populated by @no_env_required decorator) @@ -63,6 +64,7 @@ class State: def __init__(self): self._manager = None self._mode = 'remote' + self._dry_run = False self._heading_printed = False self._current_command = None @@ -111,6 +113,10 @@ def print_heading(manager, mode: str): heading.append(", hostname: ") heading.append(_hostname, style="blue") + if manager.dry_run: + heading.append(" ") + heading.append("[DRY-RUN]", style="bold yellow") + console.print(heading) @@ -172,8 +178,10 @@ def version_callback(value: bool): def main( environment: Optional[str] = typer.Option(None, "--env", "-e", help="Environment ID"), mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"), + dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show commands without executing"), version: bool = typer.Option(False, "--version", "-v", callback=version_callback, is_eager=True, help="Show version and exit"), ): """Mantis CLI - Docker deployment tool.""" state._mode = mode - state._manager = get_manager(environment, mode) + state._dry_run = dry_run + state._manager = get_manager(environment, mode, dry_run=dry_run) diff --git a/mantis/config.py b/mantis/config.py index 389fec5..d96fbce 100644 --- a/mantis/config.py +++ b/mantis/config.py @@ -167,19 +167,26 @@ def load_template_config() -> dict: def check_config(config): - # Load config template file - template = load_template_config() - - # validate config file - config_keys_only = find_keys_only_in_config(config, template) - - # remove custom connections - config_keys_only = list(filter(lambda x: not x.startswith('connections.'), config_keys_only)) - - if config_keys_only: - template_link = CLI.link('https://github.com/PragmaticMates/mantis-cli/blob/master/mantis/mantis.tpl', - 'template') + """Validate config using Pydantic schema.""" + from pydantic import ValidationError + from mantis.schema import validate_config + + try: + validate_config(config) + CLI.success("Config passed validation.") + except ValidationError as e: + errors = [] + for error in e.errors(): + loc = '.'.join(str(l) for l in error['loc']) + msg = error['msg'] + errors.append(f" - {loc}: {msg}") + + template_link = CLI.link( + 'https://github.com/PragmaticMates/mantis-cli/blob/master/mantis/mantis.tpl', + 'template' + ) CLI.error( - f"Config file validation failed. Unknown config keys: {config_keys_only}. Check {template_link} for available attributes.") - - CLI.success(f"Config passed validation.") + f"Config validation failed:\n" + + '\n'.join(errors) + + f"\n\nCheck {template_link} for available attributes." + ) diff --git a/mantis/environment.py b/mantis/environment.py index 16c8b87..7dc3efc 100644 --- a/mantis/environment.py +++ b/mantis/environment.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from mantis.helpers import CLI @@ -29,11 +30,11 @@ def setup(self): if not env_path.is_dir(): CLI.error(f"Environment path '{self.path}' is not directory") - for dirpath, directories, files in env_path.walk(): + for dirpath, directories, files in os.walk(self.path): environment_filenames = [f for f in files if f.endswith('.env')] encrypted_environment_filenames = [f for f in files if f.endswith('.env.encrypted')] - self.files = [str(dirpath / f) for f in environment_filenames] - self.encrypted_files = [str(dirpath / f) for f in encrypted_environment_filenames] + self.files = [os.path.join(dirpath, f) for f in environment_filenames] + self.encrypted_files = [os.path.join(dirpath, f) for f in encrypted_environment_filenames] def setup_single_mode(self): """ diff --git a/mantis/helpers.py b/mantis/helpers.py index 0b466fe..dfdc484 100644 --- a/mantis/helpers.py +++ b/mantis/helpers.py @@ -1,6 +1,8 @@ import sys +from contextlib import contextmanager from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn from rich.text import Text # Shared console instance @@ -19,6 +21,26 @@ def error(text): _console.print(styled_text) sys.exit(1) + @staticmethod + @contextmanager + def status(message: str): + """Context manager that shows a spinner while executing.""" + with _console.status(f"[bold blue]{message}..."): + yield + + @staticmethod + @contextmanager + def progress(): + """Context manager for progress bar operations.""" + with Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.description}"), + BarColumn(), + TaskProgressColumn(), + console=_console, + ) as progress: + yield progress + @staticmethod def bold(text, end='\n'): return CLI._print(text=text, style='bold', end=end) diff --git a/mantis/managers.py b/mantis/managers.py index 2f0d26a..378d6ba 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -1,3 +1,4 @@ +import asyncio import json import os import subprocess @@ -5,6 +6,7 @@ import time import yaml from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor from datetime import datetime from pathlib import Path from time import sleep @@ -24,9 +26,10 @@ class AbstractManager(object): """ environment_id = None - def __init__(self, config_file=None, environment_id=None, mode='remote'): + def __init__(self, config_file=None, environment_id=None, mode='remote', dry_run=False): self.environment_id = environment_id self.mode = mode + self.dry_run = dry_run # config file self.config_file = config_file @@ -290,6 +293,10 @@ def check_environment_encryption(self, env_file): def cmd(self, command): command = command.strip() + if self.dry_run: + CLI.warning(f'[DRY-RUN] {command}') + return + error_message = "Error during running command '%s'" % command try: @@ -306,6 +313,9 @@ def docker_command(self, command, return_output=False, use_connection=True): cmd = f'{docker_connection} {command}' if return_output: + if self.dry_run: + CLI.warning(f'[DRY-RUN] {cmd}') + return '' result = subprocess.run(cmd, shell=True, capture_output=True, text=True) return result.stdout @@ -329,6 +339,43 @@ def docker_compose(self, command, return_output=False, use_connection=True): use_connection=use_connection ) + def run_parallel(self, commands: list, description: str = "Running"): + """ + Execute multiple shell commands in parallel using thread pool. + + Args: + commands: List of shell command strings to execute + description: Description for progress display + """ + if not commands: + return [] + + if self.dry_run: + for cmd in commands: + CLI.warning(f'[DRY-RUN] {cmd}') + return [] + + def run_cmd(cmd): + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + return result + + with CLI.progress() as progress: + task = progress.add_task(description, total=len(commands)) + results = [] + + with ThreadPoolExecutor(max_workers=min(len(commands), 4)) as executor: + futures = {executor.submit(run_cmd, cmd): cmd for cmd in commands} + + for future in futures: + try: + result = future.result() + results.append(result) + except Exception as e: + CLI.warning(f"Command failed: {e}") + progress.advance(task) + + return results + def get_container_project(self, container): """ Prints project name of given container @@ -767,6 +814,10 @@ def build(self, params=''): # Build all services using docker compose self.docker_compose(f'build {build_args} {params} --pull', use_connection=False) elif build_tool == 'docker': + # Build commands for parallel execution + docker_connection = '' # use_connection=False + build_commands = [] + for service, info in self.services_to_build().items(): platform = f"--platform={info['platform']}" if info['platform'] != '' else '' cache_from = ' '.join([f"--cache-from {cache}" for cache in info['cache_from']]) if info['cache_from'] != [] else '' @@ -777,9 +828,13 @@ def build(self, params=''): context = str(Path(self.compose_path) / info['context']) dockerfile = str(Path(context) / info['dockerfile']) - # Build service using docker - self.docker(f"build {context} {build_args} {args} {platform} {cache_from} -t {image} -f {dockerfile} {params}", - use_connection=False) + cmd = f"{docker_connection} docker build {context} {build_args} {args} {platform} {cache_from} -t {image} -f {dockerfile} {params}" + build_commands.append(cmd.strip()) + + # Run builds in parallel + if build_commands: + CLI.info(f'Building {len(build_commands)} services in parallel...') + self.run_parallel(build_commands, "Building services") else: CLI.error(f'Unknown build tool: {build_tool}. Available tools: {", ".join(available_tools)}') @@ -927,7 +982,7 @@ def deploy(self, dirty=False): CLI.info('Deploying...') if dirty: - CLI.warning('...but dirty (no zero-downtime, without cleaning)') + CLI.warning('...but dirty (no zero-downtime, without cleaning)') self.upload() self.pull() @@ -958,6 +1013,8 @@ def deploy(self, dirty=False): if not dirty: self.clean() + CLI.success('Deployment complete!') + def zero_downtime(self, service=None): """ Runs zero-downtime deployment of services (or given service) @@ -1437,7 +1494,7 @@ def resolve_environment(environment_id, config): CLI.error(f'Environment "{environment_id}" not found. Available: {", ".join(sorted(available_envs))}') -def get_manager(environment_id, mode): +def get_manager(environment_id, mode, dry_run=False): # config file config_file = find_config(environment_id) config = load_config(config_file) @@ -1461,7 +1518,7 @@ def get_manager(environment_id, mode): class MantisManager(*[manager_class] + extension_classes): pass - manager = MantisManager(config_file=config_file, environment_id=environment_id, mode=mode) + manager = MantisManager(config_file=config_file, environment_id=environment_id, mode=mode, dry_run=dry_run) # set extensions data for extension, extension_params in extensions.items(): diff --git a/mantis/schema.py b/mantis/schema.py new file mode 100644 index 0000000..978efc8 --- /dev/null +++ b/mantis/schema.py @@ -0,0 +1,83 @@ +"""Pydantic models for mantis configuration validation.""" +from typing import Dict, List, Optional, Any + +from pydantic import BaseModel, Field, model_validator + + +class ExtensionConfig(BaseModel): + """Configuration for an extension.""" + service: Optional[str] = None + + +class EncryptionConfig(BaseModel): + """Encryption configuration.""" + deterministic: bool = True + folder: str = "" + + +class ConfigsConfig(BaseModel): + """Configs folder configuration.""" + folder: str = "/.." + + +class BuildConfig(BaseModel): + """Build configuration.""" + tool: str = "compose" + args: Dict[str, str] = Field(default_factory=dict) + + +class ComposeConfig(BaseModel): + """Docker Compose configuration.""" + command: str = "docker-compose" + folder: str = "/../compose" + + +class EnvironmentConfig(BaseModel): + """Environment files configuration.""" + folder: str = "/../environments" + file_prefix: str = "" + + +class MantisConfig(BaseModel): + """Main mantis configuration schema.""" + # Extensions + extensions: Dict[str, ExtensionConfig] = Field(default_factory=dict) + + # Core settings + encryption: EncryptionConfig = Field(default_factory=EncryptionConfig) + configs: ConfigsConfig = Field(default_factory=ConfigsConfig) + build: BuildConfig = Field(default_factory=BuildConfig) + compose: ComposeConfig = Field(default_factory=ComposeConfig) + environment: EnvironmentConfig = Field(default_factory=EnvironmentConfig) + + # Deployment + zero_downtime: List[str] = Field(default_factory=list) + project_path: str = "~" + + # Connections (mutually exclusive) + connection: Optional[str] = None + connections: Dict[str, str] = Field(default_factory=dict) + + # Custom manager class + manager_class: str = "mantis.managers.BaseManager" + + model_config = {"extra": "forbid"} + + @model_validator(mode='after') + def validate_connections(self): + """Validate that only one of connection or connections is set.""" + if self.connection and self.connections: + raise ValueError( + 'Cannot define both "connection" and "connections". ' + 'Use either single connection mode or named environments, not both.' + ) + return self + + +def validate_config(config_dict: Dict[str, Any]) -> MantisConfig: + """ + Validate a config dictionary and return a MantisConfig instance. + + Raises pydantic.ValidationError with detailed error messages if validation fails. + """ + return MantisConfig.model_validate(config_dict) diff --git a/setup.py b/setup.py index 2a4f585..1b94005 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ url='https://github.com/PragmaticMates/mantis-cli', packages=find_packages(), include_package_data=True, - install_requires=['cffi', 'cryptography', 'pycryptodome', 'PyYAML', 'rich', 'typer'], + install_requires=['cffi', 'cryptography', 'pycryptodome', 'pydantic', 'PyYAML', 'rich', 'typer'], entry_points={ 'console_scripts': ['mantis=mantis.command_line:run'], }, From 5a3b9b01aab68c3dd463b10140a87547b82b6510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 09:39:25 +0100 Subject: [PATCH 13/22] refactoring containers commands --- mantis/commands/containers.py | 9 +++++---- mantis/managers.py | 31 ++++++++++++++++++------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/mantis/commands/containers.py b/mantis/commands/containers.py index ca331e1..331426c 100644 --- a/mantis/commands/containers.py +++ b/mantis/commands/containers.py @@ -33,7 +33,7 @@ def stop( containers: Optional[List[str]] = typer.Argument(None, help="Containers to stop"), ): """Stops containers""" - state.stop(join_args(containers) or None) + state.stop(containers=containers) @command(panel="Containers") @@ -41,7 +41,7 @@ def start( containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), ): """Starts containers""" - state.start(join_args(containers)) + state.start(containers=containers) @command(panel="Containers") @@ -49,15 +49,16 @@ def kill( containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), ): """Kills containers""" - state.kill(join_args(containers) or None) + state.kill(containers=containers) @command(panel="Containers") def remove( containers: Optional[List[str]] = typer.Argument(None, help="Containers to remove"), + force: bool = typer.Option(False, "--force", "-f", help="Force removal of running containers"), ): """Removes containers""" - state.remove(join_args(containers)) + state.remove(containers=containers, force=force) @command(panel="Containers") diff --git a/mantis/managers.py b/mantis/managers.py index 378d6ba..15943d6 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -1132,13 +1132,14 @@ def try_to_reload_webserver(self): except AttributeError: CLI.warning('Tried to reload webserver, but no suitable extension found!') - def stop(self, params=None): + def stop(self, containers=None): """ - Stops all or given project container + Stops all or given project containers """ CLI.info('Stopping containers...') - containers = self.get_containers() if not params else params.split(' ') + if not containers: + containers = self.get_containers() steps = len(containers) @@ -1146,13 +1147,14 @@ def stop(self, params=None): CLI.step(index + 1, steps, f'Stopping {container}') self.docker(f'container stop {container}') - def kill(self, params=None): + def kill(self, containers=None): """ - Kills all or given project container + Kills all or given project containers """ CLI.info('Killing containers...') - containers = self.get_containers() if not params else params.split(' ') + if not containers: + containers = self.get_containers() steps = len(containers) @@ -1160,13 +1162,14 @@ def kill(self, params=None): CLI.step(index + 1, steps, f'Killing {container}') self.docker(f'container kill {container}') - def start(self, params=''): + def start(self, containers=None): """ - Starts all or given project container + Starts all or given project containers """ CLI.info('Starting containers...') - containers = self.get_containers() if not params else params.split(' ') + if not containers: + containers = self.get_containers() steps = len(containers) @@ -1201,19 +1204,21 @@ def scale(self, service, scale): """ self.up(f'--no-deps --no-recreate --scale {service}={scale}') - def remove(self, params=''): + def remove(self, containers=None, force=False): """ - Removes all or given project container + Removes all or given project containers """ CLI.info('Removing containers...') - containers = self.get_containers() if params == '' else params.split(' ') + if not containers: + containers = self.get_containers() steps = len(containers) + force_flag = '-f ' if force else '' for index, container in enumerate(containers): CLI.step(index + 1, steps, f'Removing {container}') - self.docker(f'container rm {container}') + self.docker(f'container rm {force_flag}{container}') def clean(self, params=''): # todo clean on all nodes """ From 9265c013dd94b22f215b39db89d110111335d15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 09:42:19 +0100 Subject: [PATCH 14/22] refactoring exec commands --- mantis/commands/containers.py | 6 +++--- mantis/managers.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mantis/commands/containers.py b/mantis/commands/containers.py index 331426c..fe1be68 100644 --- a/mantis/commands/containers.py +++ b/mantis/commands/containers.py @@ -3,7 +3,7 @@ import typer -from mantis.app import command, state, join_args +from mantis.app import command, state @command(shortcut="l", panel="Containers") @@ -83,7 +83,7 @@ def exec_cmd( cmd: List[str] = typer.Argument(..., help="Command to execute"), ): """Executes command in container""" - state.exec(f"{container} {join_args(cmd)}") + state.exec(container=container, cmd=cmd) @command(name="exec-it", panel="Containers") @@ -92,7 +92,7 @@ def exec_it( cmd: List[str] = typer.Argument(..., help="Command to execute"), ): """Executes command in container (interactive)""" - state.exec_it(f"{container} {join_args(cmd)}") + state.exec_it(container=container, cmd=cmd) @command(name="get-container-name", panel="Containers") diff --git a/mantis/managers.py b/mantis/managers.py index 15943d6..e78a417 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -1365,19 +1365,19 @@ def ssh(self): CLI.info(f'Executing SSH connection: {self.connection}') subprocess.run(['ssh', f'{self.user}@{self.host}', '-p', str(self.port or 22)]) - def exec(self, params): + def exec(self, container: str, cmd: list): """ Executes command in container """ - container, command = params.split(' ', maxsplit=1) + command = ' '.join(cmd) CLI.info(f'Executing command "{command}" in container {container}...') self.docker(f'exec {container} {command}') - def exec_it(self, params): + def exec_it(self, container: str, cmd: list): """ Executes command in container using interactive pseudo-TTY """ - container, command = params.split(' ', maxsplit=1) + command = ' '.join(cmd) CLI.info(f'Executing command "{command}" in container {container}...') self.docker(f'exec -it {container} {command}') From 7c35a36c975b67b7e0e1df929b8175c1a9fd2545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 10:10:37 +0100 Subject: [PATCH 15/22] type annotations --- mantis/commands/compose.py | 8 +- mantis/commands/core.py | 4 +- mantis/commands/django.py | 5 +- mantis/commands/images.py | 8 +- mantis/extensions/django.py | 6 +- mantis/managers.py | 188 +++++++++++++++++++----------------- 6 files changed, 115 insertions(+), 104 deletions(-) diff --git a/mantis/commands/compose.py b/mantis/commands/compose.py index e48e5ce..e543c80 100644 --- a/mantis/commands/compose.py +++ b/mantis/commands/compose.py @@ -3,7 +3,7 @@ import typer -from mantis.app import command, state, join_args +from mantis.app import command, state @command(panel="Compose") @@ -11,7 +11,7 @@ def up( params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), ): """Calls compose up""" - state.up(join_args(params)) + state.up(params=params) @command(panel="Compose") @@ -19,7 +19,7 @@ def down( params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), ): """Calls compose down""" - state.down(join_args(params)) + state.down(params=params) @command(name="run", panel="Compose") @@ -27,4 +27,4 @@ def run_cmd( params: List[str] = typer.Argument(..., help="Compose run parameters"), ): """Calls compose run with params""" - state.run(join_args(params)) + state.run(params=params) diff --git a/mantis/commands/core.py b/mantis/commands/core.py index a0ef879..612e954 100644 --- a/mantis/commands/core.py +++ b/mantis/commands/core.py @@ -3,7 +3,7 @@ import typer -from mantis.app import command, state, join_args +from mantis.app import command, state @command(shortcut="s") @@ -25,7 +25,7 @@ def clean( params: Optional[List[str]] = typer.Argument(None, help="Clean parameters"), ): """Clean images, containers, networks""" - state.clean(join_args(params)) + state.clean(params=params) @command(shortcut="u", panel="Files") diff --git a/mantis/commands/django.py b/mantis/commands/django.py index 51f9331..12470f6 100644 --- a/mantis/commands/django.py +++ b/mantis/commands/django.py @@ -3,7 +3,7 @@ import typer -from mantis.app import command, state, join_args +from mantis.app import command, state @command(panel="Django") @@ -18,8 +18,7 @@ def manage( args: Optional[List[str]] = typer.Argument(None, help="Command arguments"), ): """Runs Django manage command""" - full_cmd = cmd + (' ' + join_args(args) if args else '') - state.manage(full_cmd) + state.manage(cmd=cmd, args=args) @command(name="send-test-email", panel="Django") diff --git a/mantis/commands/images.py b/mantis/commands/images.py index 947ee8d..9ce8349 100644 --- a/mantis/commands/images.py +++ b/mantis/commands/images.py @@ -3,7 +3,7 @@ import typer -from mantis.app import command, state, join_args +from mantis.app import command, state @command(shortcut="b", panel="Images") @@ -11,7 +11,7 @@ def build( services: Optional[List[str]] = typer.Argument(None, help="Services to build"), ): """Builds all services with Dockerfiles""" - state.build(join_args(services)) + state.build(services=services) @command(shortcut="pl", panel="Images") @@ -19,7 +19,7 @@ def pull( services: Optional[List[str]] = typer.Argument(None, help="Services to pull"), ): """Pulls required images for services""" - state.pull(join_args(services)) + state.pull(services=services) @command(shortcut="p", panel="Images") @@ -27,7 +27,7 @@ def push( services: Optional[List[str]] = typer.Argument(None, help="Services to push"), ): """Push built images to repository""" - state.push(join_args(services)) + state.push(services=services) @command(name="get-image-name", panel="Images") diff --git a/mantis/extensions/django.py b/mantis/extensions/django.py index 9968c6d..d6a75b9 100644 --- a/mantis/extensions/django.py +++ b/mantis/extensions/django.py @@ -22,10 +22,12 @@ def shell(self): CLI.info('Connecting to Django shell...') self.docker(f'exec -i {self.django_container} python manage.py shell') - def manage(self, params): + def manage(self, cmd: str, args: list = None): """Runs Django manage command""" CLI.info('Django manage...') - self.docker(f'exec -ti {self.django_container} python manage.py {params}') + args_str = ' '.join(args) if args else '' + full_cmd = f'{cmd} {args_str}'.strip() + self.docker(f'exec -ti {self.django_container} python manage.py {full_cmd}') def send_test_email(self): """Sends test email to admins""" diff --git a/mantis/managers.py b/mantis/managers.py index e78a417..5a4fdef 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -10,6 +10,7 @@ from datetime import datetime from pathlib import Path from time import sleep +from typing import Optional, List, Dict, Any, Tuple from rich.console import Console from rich.table import Table @@ -26,7 +27,7 @@ class AbstractManager(object): """ environment_id = None - def __init__(self, config_file=None, environment_id=None, mode='remote', dry_run=False): + def __init__(self, config_file: str = None, environment_id: str = None, mode: str = 'remote', dry_run: bool = False): self.environment_id = environment_id self.mode = mode self.dry_run = dry_run @@ -49,18 +50,18 @@ def __init__(self, config_file=None, environment_id=None, mode='remote', dry_run self.encrypt_deterministically = self.config['encryption']['deterministic'] @property - def host(self): + def host(self) -> Optional[str]: return self.connection_details['host'] if self.connection_details else None @property - def user(self): + def user(self) -> Optional[str]: return self.connection_details['user'] if self.connection_details else None @property - def port(self): + def port(self) -> Optional[str]: return self.connection_details['port'] if self.connection_details else None - def parse_ssh_connection(self, connection): + def parse_ssh_connection(self, connection: str) -> Dict[str, str]: return { 'host': connection.split("@")[1].split(':')[0], 'user': connection.split("@")[0].split('://')[1], @@ -68,7 +69,7 @@ def parse_ssh_connection(self, connection): } @property - def connection_details(self): + def connection_details(self) -> Optional[Dict[str, Optional[str]]]: # In single connection mode, env.id is None but we still have a connection if not self.single_connection_mode and self.env.id is None: return None @@ -118,7 +119,7 @@ def connection_details(self): return details @property - def docker_connection(self): + def docker_connection(self) -> str: # In single connection mode or when env.id contains 'local', no extra connection needed if not self.single_connection_mode and (self.env.id is None or 'local' in self.env.id): return '' @@ -135,7 +136,7 @@ def docker_connection(self): return '' - def init_config(self, config): + def init_config(self, config: Dict[str, Any]) -> None: check_config(config) config_file_path = str(Path(self.config_file).parent) @@ -174,7 +175,7 @@ def normalize(p): elif self.environment_id: self.compose_path = normalize(str(Path(self.config['compose']['folder']) / self.environment_id)) - def init_environment(self): + def init_environment(self) -> None: if self.single_connection_mode: # Single connection mode: no environment_id required self.env = Environment( @@ -219,7 +220,7 @@ def init_environment(self): # Read compose files self.compose_config = self.read_compose_configs() - def are_env_files_in_sync(self, env_file): + def are_env_files_in_sync(self, env_file: str) -> bool: """ Checks if .env and .env.encrypted files are in sync. Returns True if they match, False otherwise. @@ -243,7 +244,7 @@ def are_env_files_in_sync(self, env_file): except Exception: return False - def check_environment_encryption(self, env_file): + def check_environment_encryption(self, env_file: str) -> None: decrypted_environment = self.decrypt_env(env_file=env_file, return_value=True) # .env.encrypted loaded_environment = self.env.load(env_file) # .env @@ -290,7 +291,7 @@ def check_environment_encryption(self, env_file): else: CLI.success(f'Encrypted and decrypted environments DO match [{env_file}]...') - def cmd(self, command): + def cmd(self, command: str) -> None: command = command.strip() if self.dry_run: @@ -307,7 +308,7 @@ def cmd(self, command): except OSError as e: CLI.error(f"{error_message}: {e}") - def docker_command(self, command, return_output=False, use_connection=True): + def docker_command(self, command: str, return_output: bool = False, use_connection: bool = True) -> Optional[str]: docker_connection = self.docker_connection if use_connection else '' cmd = f'{docker_connection} {command}' @@ -321,14 +322,14 @@ def docker_command(self, command, return_output=False, use_connection=True): self.cmd(cmd) - def docker(self, command, return_output=False, use_connection=True): + def docker(self, command: str, return_output: bool = False, use_connection: bool = True) -> Optional[str]: return self.docker_command( command=f'docker {command}', return_output=return_output, use_connection=use_connection ) - def docker_compose(self, command, return_output=False, use_connection=True): + def docker_compose(self, command: str, return_output: bool = False, use_connection: bool = True) -> Optional[str]: compose_command = self.config['compose']['command'] compose_files = ' '.join([f'-f {compose_file}' for compose_file in self.compose_files]) @@ -339,7 +340,7 @@ def docker_compose(self, command, return_output=False, use_connection=True): use_connection=use_connection ) - def run_parallel(self, commands: list, description: str = "Running"): + def run_parallel(self, commands: List[str], description: str = "Running") -> List[Any]: """ Execute multiple shell commands in parallel using thread pool. @@ -376,7 +377,7 @@ def run_cmd(cmd): return results - def get_container_project(self, container): + def get_container_project(self, container: str) -> Optional[str]: """ Prints project name of given container :param container: container name @@ -390,13 +391,15 @@ def get_container_project(self, container): return None - def get_containers(self, prefix='', exclude=[], only_running=False): + def get_containers(self, prefix: str = '', exclude: List[str] = None, only_running: bool = False) -> List[str]: """ Prints all project containers :param prefix: container prefix :param exclude: exclude containers :return: list of container names """ + if exclude is None: + exclude = [] containers = self.docker(f'container ls {"" if only_running else "-a"} --format \'{{{{.Names}}}}\'', return_output=True) \ .strip('\n').strip().split('\n') @@ -420,13 +423,13 @@ class BaseManager(AbstractManager): Base manager contains methods which should be available to call using CLI """ - def check_config(self): + def check_config(self) -> None: """ Validates config file according to template """ check_config(self.config) - def read_key(self): + def read_key(self) -> Optional[str]: """ Returns value of mantis encryption key """ @@ -437,7 +440,7 @@ def read_key(self): with open(self.key_file, "r") as f: return f.read().strip() - def generate_key(self): + def generate_key(self) -> None: """ Creates new encryption key """ @@ -449,7 +452,7 @@ def generate_key(self): CLI.pink(key) CLI.danger(f'Save it to {self.key_file} and keep safe !!!') - def encrypt_env(self, params='', env_file=None, return_value=False): + def encrypt_env(self, params: str = '', env_file: Optional[str] = None, return_value: bool = False) -> Optional[Dict[str, str]]: """ Encrypts all environment files (force param skips user confirmation) """ @@ -515,7 +518,7 @@ def encrypt_env(self, params='', env_file=None, return_value=False): else: CLI.warning(f'Save it to {env_file_encrypted} manually.') - def decrypt_env(self, params='', env_file=None, return_value=False): + def decrypt_env(self, params: str = '', env_file: Optional[str] = None, return_value: bool = False) -> Optional[Dict[str, str]]: """ Decrypts all environment files (force param skips user confirmation) """ @@ -586,7 +589,7 @@ def decrypt_env(self, params='', env_file=None, return_value=False): else: CLI.warning(f'Save it to {env_file} manually.') - def check_env(self): + def check_env(self) -> None: """ Compares encrypted and decrypted env files """ @@ -613,13 +616,13 @@ def check_env(self): # check encryption values self.check_environment_encryption(env_file) - def contexts(self): + def contexts(self) -> None: """ Prints all docker contexts """ self.cmd('docker context ls') - def create_context(self): + def create_context(self) -> None: """ Creates docker context using user inputs """ @@ -660,14 +663,14 @@ def create_context(self): self.cmd(command) self.contexts() - def get_container_suffix(self, service): + def get_container_suffix(self, service: str) -> str: """ Returns the suffix used for containers for given service """ delimiter = '-' return f'{delimiter}{service}' - def get_container_name(self, service): + def get_container_name(self, service: str) -> str: """ Constructs container name with project prefix for given service """ @@ -675,27 +678,27 @@ def get_container_name(self, service): prefix = self.get_project_by_service(service) return f'{prefix}{suffix}'.replace('_', '-') - def get_service_containers(self, service): + def get_service_containers(self, service: str) -> List[str]: """ Prints container names of given service """ containers = self.docker_compose("ps --format '{{.Names}}' %s" % service, return_output=True) return containers.strip().split('\n') - def get_number_of_containers(self, service): + def get_number_of_containers(self, service: str) -> int: """ Prints number of containers for given service """ return len(self.get_service_containers(service)) - def get_image_suffix(self, service): + def get_image_suffix(self, service: str) -> str: """ Returns the suffix used for image for given service """ delimiter = '_' return f'{delimiter}{service}' - def get_image_name(self, service): + def get_image_name(self, service: str) -> str: """ Constructs image name for given service """ @@ -703,7 +706,7 @@ def get_image_name(self, service): prefix = self.get_project_by_service(service) return f'{prefix}{suffix}'.replace('-', '_') - def has_healthcheck(self, container): + def has_healthcheck(self, container: str) -> bool: """ Checks if given container has defined healthcheck """ @@ -711,7 +714,7 @@ def has_healthcheck(self, container): return healthcheck_config and healthcheck_config.get('Test') != ['NONE'] - def get_healthcheck_start_period(self, container): + def get_healthcheck_start_period(self, container: str) -> Optional[float]: """ Returns healthcheck start period for given container (if any) """ @@ -723,7 +726,7 @@ def get_healthcheck_start_period(self, container): # TODO: return default value as fallback? return None - def check_health(self, container): + def check_health(self, container: str) -> Optional[Tuple[bool, str]]: """ Checks current health of given container """ @@ -736,7 +739,7 @@ def check_health(self, container): else: return False, status - def healthcheck(self, container=None): + def healthcheck(self, container: str) -> Optional[bool]: """ Execute health-check of given project container """ @@ -789,12 +792,13 @@ def healthcheck(self, container=None): sleep(start_period) return None - def build(self, params=''): + def build(self, services: Optional[List[str]] = None) -> None: """ Builds all services with Dockerfiles """ CLI.info(f'Building...') - CLI.info(f'Params = {params}') + params = ' '.join(services) if services else '' + CLI.info(f'Services = {params}') # Construct build args from config build_args = self.config['build']['args'] @@ -838,7 +842,7 @@ def build(self, params=''): else: CLI.error(f'Unknown build tool: {build_tool}. Available tools: {", ".join(available_tools)}') - def project_services(self): + def project_services(self) -> Dict[str, List[str]]: """ Returns project names by compose files """ @@ -853,7 +857,7 @@ def project_services(self): return projects - def get_project_by_service(self, service): + def get_project_by_service(self, service: str) -> Optional[str]: project_services = self.project_services() for project, services in project_services.items(): @@ -862,7 +866,7 @@ def get_project_by_service(self, service): return None - def services(self, compose_file=None): + def services(self, compose_file: Optional[str] = None) -> List[str]: """ Returns all defined services """ @@ -879,7 +883,7 @@ def services(self, compose_file=None): return services - def services_to_build(self, compose_file=None): + def services_to_build(self, compose_file: Optional[str] = None) -> Dict[str, Dict[str, Any]]: """ Prints all services which will be build """ @@ -908,27 +912,29 @@ def services_to_build(self, compose_file=None): return data - def push(self, params=''): + def push(self, services: Optional[List[str]] = None) -> None: """ Push built images to repository """ CLI.info(f'Pushing...') - CLI.info(f'Params = {params}') + params = ' '.join(services) if services else '' + CLI.info(f'Services = {params}') # Push using docker compose self.docker_compose(f'push {params}', use_connection=False) - def pull(self, params=''): + def pull(self, services: Optional[List[str]] = None) -> None: """ Pulls required images for services """ CLI.info('Pulling...') - CLI.info(f'Params = {params}') + params = ' '.join(services) if services else '' + CLI.info(f'Services = {params}') # Pull using docker compose self.docker_compose(f'pull {params}') - def upload(self): + def upload(self) -> None: """ Uploads mantis config, compose file
and environment files to server """ @@ -950,7 +956,7 @@ def upload(self): else: CLI.info(f'{self.config_file} does not exists. Skipping...') - def restart(self, service=None): + def restart(self, service: Optional[str] = None) -> None: """ Restarts all containers by calling compose down and up """ @@ -975,7 +981,7 @@ def restart(self, service=None): CLI.step(3, 3, 'Prune Docker images') self.clean() - def deploy(self, dirty=False): + def deploy(self, dirty: bool = False) -> None: """ Runs deployment process: uploads files, pulls images, runs zero-downtime deployment, removes suffixes, reloads webserver, clean """ @@ -993,7 +999,7 @@ def deploy(self, dirty=False): self.zero_downtime() # Preserve number of scaled containers - scale_param = '' + scale_param: List[str] = [] if is_running: scales = {} for service in self.services(): @@ -1004,9 +1010,9 @@ def deploy(self, dirty=False): if number_of_containers > replicas: scales[service] = number_of_containers - scale_param = ' '.join([f'--scale {service}={scale}' for service, scale in scales.items()]) + scale_param = [f'--scale {service}={scale}' for service, scale in scales.items()] - self.up(scale_param) + self.up(scale_param if scale_param else None) self.remove_suffixes() self.try_to_reload_webserver() @@ -1015,7 +1021,7 @@ def deploy(self, dirty=False): CLI.success('Deployment complete!') - def zero_downtime(self, service=None): + def zero_downtime(self, service: Optional[str] = None) -> None: """ Runs zero-downtime deployment of services (or given service) """ @@ -1071,7 +1077,7 @@ def zero_downtime(self, service=None): # reload webserver self.try_to_reload_webserver() - def remove_suffixes(self, prefix=''): + def remove_suffixes(self, prefix: str = '') -> None: """ Removes numerical suffixes from container names (if scale == 1) """ @@ -1100,7 +1106,7 @@ def remove_suffixes(self, prefix=''): CLI.info(f'Removing suffix of container {container}') self.docker(f'container rename {container} {new_container}') - def restart_service(self, service): + def restart_service(self, service: str) -> None: """ Stops, removes and recreates container for given service """ @@ -1120,10 +1126,10 @@ def restart_service(self, service): CLI.info(f'{app_container} was not running') CLI.info(f'Creating new container [{container}]...') - self.up(f'--no-deps --no-recreate {service}') + self.up(['--no-deps', '--no-recreate', service]) self.remove_suffixes(prefix=container) - def try_to_reload_webserver(self): + def try_to_reload_webserver(self) -> None: """ Tries to reload webserver (if suitable extension is available) """ @@ -1132,7 +1138,7 @@ def try_to_reload_webserver(self): except AttributeError: CLI.warning('Tried to reload webserver, but no suitable extension found!') - def stop(self, containers=None): + def stop(self, containers: Optional[List[str]] = None) -> None: """ Stops all or given project containers """ @@ -1147,7 +1153,7 @@ def stop(self, containers=None): CLI.step(index + 1, steps, f'Stopping {container}') self.docker(f'container stop {container}') - def kill(self, containers=None): + def kill(self, containers: Optional[List[str]] = None) -> None: """ Kills all or given project containers """ @@ -1162,7 +1168,7 @@ def kill(self, containers=None): CLI.step(index + 1, steps, f'Killing {container}') self.docker(f'container kill {container}') - def start(self, containers=None): + def start(self, containers: Optional[List[str]] = None) -> None: """ Starts all or given project containers """ @@ -1177,34 +1183,37 @@ def start(self, containers=None): CLI.step(index + 1, steps, f'Starting {container}') self.docker(f'container start {container}') - def run(self, params): + def run(self, params: List[str]) -> None: """ Calls compose run with params """ - CLI.info(f'Running {params}...') - self.docker_compose(f'run {params}') + params_str = ' '.join(params) if params else '' + CLI.info(f'Running {params_str}...') + self.docker_compose(f'run {params_str}') - def up(self, params=''): + def up(self, params: Optional[List[str]] = None) -> None: """ Calls compose up (with optional params) """ - CLI.info(f'Starting up {params}...') - self.docker_compose(f'up {params} -d') + params_str = ' '.join(params) if params else '' + CLI.info(f'Starting up {params_str}...') + self.docker_compose(f'up {params_str} -d') - def down(self, params=''): + def down(self, params: Optional[List[str]] = None) -> None: """ Calls compose down (with optional params) """ - CLI.info(f'Running down {params}...') - self.docker_compose(f'down {params}') + params_str = ' '.join(params) if params else '' + CLI.info(f'Running down {params_str}...') + self.docker_compose(f'down {params_str}') - def scale(self, service, scale): + def scale(self, service: str, scale: int) -> None: """ Scales service to given scale """ - self.up(f'--no-deps --no-recreate --scale {service}={scale}') + self.up([f'--no-deps', '--no-recreate', '--scale', f'{service}={scale}']) - def remove(self, containers=None, force=False): + def remove(self, containers: Optional[List[str]] = None, force: bool = False) -> None: """ Removes all or given project containers """ @@ -1220,17 +1229,18 @@ def remove(self, containers=None, force=False): CLI.step(index + 1, steps, f'Removing {container}') self.docker(f'container rm {force_flag}{container}') - def clean(self, params=''): # todo clean on all nodes + def clean(self, params: Optional[List[str]] = None) -> None: # todo clean on all nodes """ Clean images, containers, networks """ CLI.info('Cleaning...') + params_str = ' '.join(params) if params else '' # self.docker(f'builder prune') - self.docker(f'system prune {params} -a --force') + self.docker(f'system prune {params_str} -a --force') # self.docker(f'container prune') # self.docker(f'container prune --force') - def status(self): + def status(self) -> None: """ Prints images and containers """ @@ -1302,7 +1312,7 @@ def status(self): console.print(containers_table) - def networks(self): + def networks(self) -> None: """ Prints docker networks """ @@ -1326,7 +1336,7 @@ def networks(self): containers = ', '.join(containers.split()) print(f'{network}\t{containers}'.strip()) - def logs(self, params=None): + def logs(self, params: Optional[str] = None) -> None: """ Prints logs of all or given project container """ @@ -1340,7 +1350,7 @@ def logs(self, params=None): CLI.step(index + 1, steps, f'{container} logs') self.docker(f'logs {container} {lines}') - def bash(self, params): + def bash(self, params: str) -> None: """ Runs bash in container """ @@ -1348,14 +1358,14 @@ def bash(self, params): self.docker(f'exec -it --user root {params} /bin/bash') # self.docker_compose(f'run --entrypoint /bin/bash {container}') - def sh(self, params): + def sh(self, params: str) -> None: """ Runs sh in container """ CLI.info('Logging to container...') self.docker(f'exec -it --user root {params} /bin/sh') - def ssh(self): + def ssh(self) -> None: if not self.connection: CLI.error('Missing connection details') @@ -1381,7 +1391,7 @@ def exec_it(self, container: str, cmd: list): CLI.info(f'Executing command "{command}" in container {container}...') self.docker(f'exec -it {container} {command}') - def get_healthcheck_config(self, container): + def get_healthcheck_config(self, container: str) -> Optional[Dict[str, Any]]: """ Prints health-check config (if any) of given container """ @@ -1393,7 +1403,7 @@ def get_healthcheck_config(self, container): return None - def read_compose_configs(self): + def read_compose_configs(self) -> Dict[str, Any]: """ Returns merged compose configs """ @@ -1406,7 +1416,7 @@ def read_compose_configs(self): return config - def get_deploy_replicas(self, service): + def get_deploy_replicas(self, service: str) -> int: """ Returns default number of deploy replicas of given services """ @@ -1423,7 +1433,7 @@ def get_deploy_replicas(self, service): return replicas - def backup_volume(self, volume): + def backup_volume(self, volume: str) -> None: # backups folder backup_path = str(Path.cwd() / 'backups') @@ -1440,7 +1450,7 @@ def backup_volume(self, volume): self.docker(command) - def restore_volume(self, volume, file): + def restore_volume(self, volume: str, file: str) -> None: # backups folder backup_path = str(Path.cwd() / 'backups') @@ -1453,8 +1463,8 @@ def restore_volume(self, volume, file): self.docker(command) -def get_extension_classes(extensions): - extension_classes = [] +def get_extension_classes(extensions: List[str]) -> List[type]: + extension_classes: List[type] = [] # extensions for extension in extensions: @@ -1465,7 +1475,7 @@ def get_extension_classes(extensions): return extension_classes -def resolve_environment(environment_id, config): +def resolve_environment(environment_id: Optional[str], config: Dict[str, Any]) -> Optional[str]: """ Resolves environment prefix to full environment ID. @@ -1499,7 +1509,7 @@ def resolve_environment(environment_id, config): CLI.error(f'Environment "{environment_id}" not found. Available: {", ".join(sorted(available_envs))}') -def get_manager(environment_id, mode, dry_run=False): +def get_manager(environment_id: Optional[str], mode: str, dry_run: bool = False) -> BaseManager: # config file config_file = find_config(environment_id) config = load_config(config_file) From 662bb3e4894c274fce80b242930e469ba54f6382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 22:03:42 +0100 Subject: [PATCH 16/22] deployment rollback if zero-downtime container is not healthy --- mantis/helpers.py | 24 ++++++++++++++++++ mantis/managers.py | 61 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/mantis/helpers.py b/mantis/helpers.py index dfdc484..9a9d925 100644 --- a/mantis/helpers.py +++ b/mantis/helpers.py @@ -1,3 +1,4 @@ +import select import sys from contextlib import contextmanager @@ -79,6 +80,29 @@ def link(uri, label=None): label = uri return f'[link={uri}]{label}[/link]' + @staticmethod + def timed_confirm(prompt: str, timeout: int = 10, default: bool = False) -> bool: + """ + Ask user for confirmation with a timeout. + Returns default value if user doesn't respond within timeout. + """ + default_str = "Y/n" if default else "y/N" + _console.print(f"[yellow]{prompt} ({default_str}) [dim][{timeout}s timeout][/dim][/yellow]", end=" ") + sys.stdout.flush() + + try: + ready, _, _ = select.select([sys.stdin], [], [], timeout) + if ready: + response = sys.stdin.readline().strip().lower() + if response == '': + return default + return response in ('y', 'yes') + else: + _console.print(f"\n[dim]Timeout reached, using default: {'yes' if default else 'no'}[/dim]") + return default + except Exception: + return default + def nested_set(dic, keys, value): for key in keys[:-1]: diff --git a/mantis/managers.py b/mantis/managers.py index 5a4fdef..d6fa200 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -776,6 +776,10 @@ def healthcheck(self, container: str) -> Optional[bool]: if retries > 1: sleep(interval) + + # All retries exhausted, container is unhealthy + console.print(f'[red bold]Container {container} failed to become healthy after {retries} retries[/red bold]') + return False else: CLI.warning(f"Container '{container}' doesn't have healthcheck command defined. Looking for start period value...") start_period = self.get_healthcheck_start_period(container) @@ -996,7 +1000,9 @@ def deploy(self, dirty: bool = False) -> None: is_running = len(self.get_containers(only_running=True)) != 0 if is_running and not dirty: - self.zero_downtime() + if not self.zero_downtime(): + CLI.danger('Deployment aborted.') + return # Preserve number of scaled containers scale_param: List[str] = [] @@ -1021,16 +1027,18 @@ def deploy(self, dirty: bool = False) -> None: CLI.success('Deployment complete!') - def zero_downtime(self, service: Optional[str] = None) -> None: + def zero_downtime(self, service: Optional[str] = None) -> bool: """ - Runs zero-downtime deployment of services (or given service) + Runs zero-downtime deployment of services (or given service). + Returns True if zero downtime was successful, False otherwise (rollback performed). """ if not service: zero_downtime_services = self.config['zero_downtime'] for index, service in enumerate(zero_downtime_services): CLI.step(index + 1, len(zero_downtime_services), f'Zero downtime services: {zero_downtime_services}') - self.zero_downtime(service) - return + if not self.zero_downtime(service): + return False # Rollback happened, stop processing + return True container_prefix = self.get_container_name(service) @@ -1039,7 +1047,7 @@ def zero_downtime(self, service: Optional[str] = None) -> None: if num_containers == 0: CLI.danger(f'Old container for service {service} not found. Skipping zero-downtime deployment...') - return + return True # run new containers scale = num_containers * 2 @@ -1047,9 +1055,46 @@ def zero_downtime(self, service: Optional[str] = None) -> None: # healthcheck new_containers = self.get_containers(prefix=container_prefix, exclude=old_containers, only_running=True) + unhealthy_containers = [] for new_container in new_containers: - self.healthcheck(container=new_container) + is_healthy = self.healthcheck(container=new_container) + if is_healthy is False: + unhealthy_containers.append(new_container) + + # Handle unhealthy containers + if unhealthy_containers: + console = Console() + console.print(f'\n[red bold]⚠ Unhealthy containers detected: {", ".join(unhealthy_containers)}[/red bold]\n') + + # Show logs of unhealthy containers + for container in unhealthy_containers: + console.print(f'[yellow]Logs for {container}:[/yellow]') + self.docker(f'logs {container} --tail 50') + console.print('') + + # Ask user if they want to rollback + rollback = CLI.timed_confirm( + "Rollback deployment? (stop new containers and keep old ones)", + timeout=10, + default=False + ) + + if rollback: + console.print(f'\n[yellow]Rolling back deployment for service {service}...[/yellow]') + + # Stop and remove unhealthy new containers + for new_container in new_containers: + if new_container in self.get_containers(): + CLI.info(f'Stopping new container [{new_container}]...') + self.docker(f'container stop {new_container}') + CLI.info(f'Removing new container [{new_container}]...') + self.docker(f'container rm {new_container}') + + CLI.success(f'Rollback complete. Old containers preserved: {old_containers}') + return False # Not successful zero-downtime. Rollback performed + else: + console.print(f'\n[yellow]Continuing deployment with potentially unhealthy containers...[/yellow]') # reload webserver self.try_to_reload_webserver() @@ -1077,6 +1122,8 @@ def zero_downtime(self, service: Optional[str] = None) -> None: # reload webserver self.try_to_reload_webserver() + return True # Successful zero-downtime. No rollback + def remove_suffixes(self, prefix: str = '') -> None: """ Removes numerical suffixes from container names (if scale == 1) From 4941f87e14ee0408734fd07ed80faf0929a0b89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Sat, 3 Jan 2026 22:10:23 +0100 Subject: [PATCH 17/22] rename command --- mantis/commands/containers.py | 9 +++++++++ mantis/managers.py | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/mantis/commands/containers.py b/mantis/commands/containers.py index fe1be68..d99c37b 100644 --- a/mantis/commands/containers.py +++ b/mantis/commands/containers.py @@ -61,6 +61,15 @@ def remove( state.remove(containers=containers, force=force) +@command(panel="Containers") +def rename( + container: str = typer.Argument(..., help="Container to rename"), + new_name: str = typer.Argument(..., help="New container name"), +): + """Rename container""" + state.rename(container=container, new_name=new_name) + + @command(panel="Containers") def bash( container: str = typer.Argument(..., help="Container name"), diff --git a/mantis/managers.py b/mantis/managers.py index d6fa200..d995e1d 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -1276,6 +1276,13 @@ def remove(self, containers: Optional[List[str]] = None, force: bool = False) -> CLI.step(index + 1, steps, f'Removing {container}') self.docker(f'container rm {force_flag}{container}') + def rename(self, container: str, new_name: str) -> None: + """ + Renames container to a new name + """ + CLI.info(f'Renaming container {container} to {new_name}') + self.docker(f'container rename {container} {new_name}') + def clean(self, params: Optional[List[str]] = None) -> None: # todo clean on all nodes """ Clean images, containers, networks From 835e8d970184dcd6db7fa6fa9d8f004c993a27b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Thu, 8 Jan 2026 17:52:46 +0100 Subject: [PATCH 18/22] rolling update --- mantis/commands/core.py | 11 +++- mantis/managers.py | 134 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/mantis/commands/core.py b/mantis/commands/core.py index 612e954..b5d001d 100644 --- a/mantis/commands/core.py +++ b/mantis/commands/core.py @@ -15,9 +15,18 @@ def status(): @command(shortcut="d") def deploy( dirty: bool = typer.Option(False, "--dirty", help="Skip clean step"), + strategy: str = typer.Option("blue-green", "--strategy", "-s", help="Deployment strategy: rolling (one-by-one) or blue-green (scale 2x)"), ): """Runs deployment process""" - state.deploy(dirty=dirty) + state.deploy(dirty=dirty, strategy=strategy) + + +@command(name="rolling-update", shortcut="ru") +def rolling_update( + service: Optional[str] = typer.Argument(None, help="Service to update (default: all zero_downtime services)"), +): + """Performs rolling update of containers one-by-one""" + state.rolling_update(service=service) @command(shortcut="c") diff --git a/mantis/managers.py b/mantis/managers.py index d995e1d..4056056 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -985,9 +985,13 @@ def restart(self, service: Optional[str] = None) -> None: CLI.step(3, 3, 'Prune Docker images') self.clean() - def deploy(self, dirty: bool = False) -> None: + def deploy(self, dirty: bool = False, strategy: str = 'blue-green') -> None: """ Runs deployment process: uploads files, pulls images, runs zero-downtime deployment, removes suffixes, reloads webserver, clean + + Args: + dirty: Skip zero-downtime and cleaning steps + strategy: Deployment strategy - 'rolling' (one-by-one) or 'blue-green' (scale 2x) """ CLI.info('Deploying...') @@ -1000,8 +1004,15 @@ def deploy(self, dirty: bool = False) -> None: is_running = len(self.get_containers(only_running=True)) != 0 if is_running and not dirty: - if not self.zero_downtime(): - CLI.danger('Deployment aborted.') + if strategy == 'rolling': + CLI.info('Using rolling update strategy (one-by-one)...') + success = self.rolling_update() + else: # blue-green + CLI.info('Using blue-green strategy (scale 2x)...') + success = self.zero_downtime() + + if not success: + CLI.danger('Deployment aborted due to rollback.') return # Preserve number of scaled containers @@ -1124,6 +1135,123 @@ def zero_downtime(self, service: Optional[str] = None) -> bool: return True # Successful zero-downtime. No rollback + def rolling_update(self, service: Optional[str] = None) -> bool: + """ + Performs rolling update of service containers one at a time. + + Flow for each container: + 1. Start 1 new container + 2. Wait for healthy + 3. Reload webserver + 4. Remove 1 old container + + Returns True if successful, False if rollback was performed. + """ + if not service: + # Process all zero_downtime services + zero_downtime_services = self.config['zero_downtime'] + for index, service in enumerate(zero_downtime_services): + CLI.step(index + 1, len(zero_downtime_services), f'Rolling update: {service}') + if not self.rolling_update(service): + return False # Rollback happened, stop processing + return True + + console = Console() + container_prefix = self.get_container_name(service) + old_containers = self.get_containers(prefix=container_prefix, only_running=True) + num_containers = len(old_containers) + + if num_containers == 0: + CLI.danger(f'No running containers for service {service}. Skipping rolling update...') + return True + + console.print(f'\n[blue]Starting rolling update for [yellow]{service}[/yellow] ({num_containers} containers)[/blue]\n') + + # Track containers we've successfully replaced + replaced_count = 0 + + for i, old_container in enumerate(old_containers): + step = i + 1 + console.print(f'[cyan]━━━ Container {step}/{num_containers} ━━━[/cyan]') + + # Step 1: Scale up by 1 (start new container) + current_count = len(self.get_containers(prefix=container_prefix, only_running=True)) + CLI.info(f'Starting new container (scaling {current_count} → {current_count + 1})...') + self.scale(service, current_count + 1) + + # Get the new container (the one that wasn't there before) + all_current = self.get_containers(prefix=container_prefix, only_running=True) + new_container = None + for c in all_current: + if c not in old_containers and c != old_container: + # Check if this is a newly created container + if new_container is None or c > new_container: # Higher suffix = newer + new_container = c + + if not new_container: + # Fallback: get the container with highest suffix + new_container = sorted(all_current)[-1] + + CLI.info(f'New container: {new_container}') + + # Step 2: Wait for healthy + is_healthy = self.healthcheck(container=new_container) + + if is_healthy is False: + # Show logs + console.print(f'\n[red bold]⚠ Container {new_container} failed health check[/red bold]\n') + console.print(f'[yellow]Logs for {new_container}:[/yellow]') + self.docker(f'logs {new_container} --tail 50') + console.print('') + + # Ask for rollback + rollback = CLI.timed_confirm( + "Rollback? (stop new container, keep remaining old ones)", + timeout=10, + default=False + ) + + if rollback: + console.print(f'\n[yellow]Rolling back...[/yellow]') + + # Stop and remove the failed new container + if new_container in self.get_containers(): + self.docker(f'container stop {new_container}') + self.docker(f'container rm {new_container}') + + remaining_old = old_containers[i:] # Containers we haven't replaced yet + CLI.success(f'Rollback complete. Preserved: {remaining_old}') + CLI.info(f'Successfully replaced {replaced_count}/{num_containers} containers before failure.') + return False + else: + console.print(f'[yellow]Continuing with potentially unhealthy container...[/yellow]') + + # Step 3: Reload webserver (new container now receiving traffic) + self.try_to_reload_webserver() + + # Step 4: Stop and remove old container + CLI.info(f'Removing old container: {old_container}') + if old_container in self.get_containers(): + self.docker(f'container stop {old_container}') + self.docker(f'container rm {old_container}') + + replaced_count += 1 + console.print(f'[green]✓ Replaced {old_container} → {new_container}[/green]\n') + + # Rename containers to clean suffixes + final_containers = self.get_containers(prefix=container_prefix, only_running=True) + for index, container in enumerate(sorted(final_containers)): + new_name = f'{container_prefix}-{index + 1}' + if container != new_name: + CLI.info(f'Renaming {container} → {new_name}') + self.docker(f'container rename {container} {new_name}') + + self.remove_suffixes(prefix=container_prefix) + self.try_to_reload_webserver() + + console.print(f'\n[green bold]✓ Rolling update complete for {service}[/green bold]\n') + return True + def remove_suffixes(self, prefix: str = '') -> None: """ Removes numerical suffixes from container names (if scale == 1) From d1716802fc8b33dd50b66f62c71ec4de92438dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Thu, 8 Jan 2026 18:55:14 +0100 Subject: [PATCH 19/22] support for chaining with optionable command arguments --- mantis/app.py | 15 ++++ mantis/command_line.py | 175 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/mantis/app.py b/mantis/app.py index a451d02..694be0a 100644 --- a/mantis/app.py +++ b/mantis/app.py @@ -49,6 +49,9 @@ # Commands that don't require environment (populated by @no_env_required decorator) NO_ENV_COMMANDS: set[str] = set() +# All registered command names (populated by @command decorator for argument parsing) +COMMAND_NAMES: set[str] = set() + # Cache hostname _hostname = socket.gethostname() @@ -142,6 +145,11 @@ def command( def decorator(func: Callable) -> Callable: cmd_name = name or func.__name__.replace('_', '-') + # Track command names for argument parsing + COMMAND_NAMES.add(cmd_name) + if shortcut: + COMMAND_NAMES.add(shortcut) + # Mark as no-env command if no_env: NO_ENV_COMMANDS.add(cmd_name) @@ -176,12 +184,19 @@ def version_callback(value: bool): @app.callback() def main( + ctx: typer.Context, environment: Optional[str] = typer.Option(None, "--env", "-e", help="Environment ID"), mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"), dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show commands without executing"), version: bool = typer.Option(False, "--version", "-v", callback=version_callback, is_eager=True, help="Show version and exit"), ): """Mantis CLI - Docker deployment tool.""" + import sys + + # Skip initialization when showing help or completions + if ctx.resilient_parsing or '--help' in sys.argv or '-h' in sys.argv: + return + state._mode = mode state._dry_run = dry_run state._manager = get_manager(environment, mode, dry_run=dry_run) diff --git a/mantis/command_line.py b/mantis/command_line.py index d173410..e86f67b 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -12,15 +12,184 @@ mantis status (single connection mode) mantis manage migrate """ -from mantis.app import app +import sys +from typing import List, Tuple, Set + +import click +import typer + +from mantis import VERSION +from mantis.app import app, state, COMMAND_NAMES, print_heading +from mantis.managers import get_manager # Import commands to register them with the app from mantis import commands # noqa: F401 +# Global options that take a value +OPTS_WITH_VALUES = {'-e', '--env', '-m', '--mode'} + + +def split_args_by_commands( + args: List[str], + command_names: Set[str] +) -> Tuple[List[str], List[Tuple[str, List[str]]]]: + """ + Split argv into global options and (command, args) groups. + + Input: ['-e', 'prod', 'build', 'web', 'push', 'deploy'] + Output: (['-e', 'prod'], [('build', ['web']), ('push', []), ('deploy', [])]) + """ + global_opts = [] + cmd_groups = [] + current_cmd = None + current_args = [] + + i = 0 + while i < len(args): + arg = args[i] + + if current_cmd is None: + # Still parsing global options + if arg.startswith('-'): + global_opts.append(arg) + # Consume value for options that take one + if arg in OPTS_WITH_VALUES and i + 1 < len(args): + i += 1 + global_opts.append(args[i]) + elif arg in command_names: + # First command found + current_cmd = arg + current_args = [] + # else: unexpected positional before command - will be handled by Typer + else: + # After first command + if arg in command_names: + # New command - save previous and start new group + cmd_groups.append((current_cmd, current_args)) + current_cmd = arg + current_args = [] + else: + # Argument for current command + current_args.append(arg) + i += 1 + + # Don't forget last command + if current_cmd is not None: + cmd_groups.append((current_cmd, current_args)) + + return global_opts, cmd_groups + + +def parse_global_options(global_opts: List[str]) -> dict: + """Parse global options into a dict.""" + result = { + 'env': None, + 'mode': 'remote', + 'dry_run': False, + 'help': False, + 'version': False, + } + + i = 0 + while i < len(global_opts): + opt = global_opts[i] + if opt in ('-e', '--env') and i + 1 < len(global_opts): + result['env'] = global_opts[i + 1] + i += 2 + elif opt in ('-m', '--mode') and i + 1 < len(global_opts): + result['mode'] = global_opts[i + 1] + i += 2 + elif opt in ('-n', '--dry-run'): + result['dry_run'] = True + i += 1 + elif opt in ('-v', '--version'): + result['version'] = True + i += 1 + elif opt in ('-h', '--help'): + result['help'] = True + i += 1 + else: + i += 1 + + return result + + +def invoke_command(click_app, cmd_name: str, cmd_args: List[str], parent_ctx): + """Invoke a single command with its arguments.""" + from mantis.helpers import CLI + + cmd = click_app.get_command(parent_ctx, cmd_name) + if cmd is None: + CLI.error(f"Unknown command: {cmd_name}") + + state._current_command = cmd_name + + # Create context for this command and invoke + try: + with cmd.make_context(cmd_name, cmd_args, parent=parent_ctx) as ctx: + cmd.invoke(ctx) + except click.exceptions.Exit: + # Normal exit (e.g., from --help) + pass + + def run(): - """Entry point for the CLI.""" - app() + """Entry point with command-aware argument parsing.""" + args = sys.argv[1:] + + # No args - show help + if not args: + app() + return + + global_opts, cmd_groups = split_args_by_commands(args, COMMAND_NAMES) + + # No commands found - delegate to Typer (handles --help, --version, errors) + if not cmd_groups: + sys.argv = [sys.argv[0]] + global_opts + app() + return + + # Parse global options + opts = parse_global_options(global_opts) + + # Handle --version + if opts['version']: + print(f"Mantis v{VERSION}") + return + + # Handle --help: show help for first command + if opts['help']: + sys.argv = [sys.argv[0]] + [cmd_groups[0][0], '--help'] + app() + return + + # Check if any command has --help in its args - delegate to Typer without initializing manager + for cmd_name, cmd_args in cmd_groups: + if '--help' in cmd_args or '-h' in cmd_args: + sys.argv = [sys.argv[0]] + [cmd_name, '--help'] + app() + return + + # Initialize state (mirrors @app.callback behavior) + state._mode = opts['mode'] + state._dry_run = opts['dry_run'] + state._manager = get_manager(opts['env'], opts['mode'], dry_run=opts['dry_run']) + + # Get Click app from Typer + click_app = typer.main.get_command(app) + + # Create parent context for shared state + # Use resilient_parsing to prevent Click from triggering no_args_is_help + try: + with click_app.make_context('mantis', [], resilient_parsing=True) as parent_ctx: + # Invoke each command in sequence + for cmd_name, cmd_args in cmd_groups: + invoke_command(click_app, cmd_name, cmd_args, parent_ctx) + except click.exceptions.Exit: + # Normal exit (e.g., from --help in command) + pass if __name__ == "__main__": From 4ce098f4eca745bd44264d8df2716b8db96b9821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Thu, 8 Jan 2026 19:10:09 +0100 Subject: [PATCH 20/22] tests, cleaner chaining solution using + as command separator --- Makefile | 3 + README.md | 83 ++++++++----- mantis/app.py | 10 +- mantis/command_line.py | 131 ++++++++++--------- tests/__init__.py | 1 + tests/test_command_line.py | 249 +++++++++++++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 101 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_command_line.py diff --git a/Makefile b/Makefile index a080601..a5306f5 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +test: + ./venv/bin/pytest tests/ -v + sdist-and-upload: make sdist make upload diff --git a/README.md b/README.md index 7d7d54b..e745ff9 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ Use `connections` (dict) when you have multiple environments like stage, product In this mode, you must specify the environment in every command: ```bash -mantis production status -mantis stage deploy +mantis -e production status +mantis -e stage deploy ``` #### Single connection mode @@ -114,20 +114,20 @@ Then you can encrypt your environment files using symmetric encryption. Every environment variable is encrypted separately instead of encrypting the whole file for better tracking of changes in VCS. ```bash -mantis encrypt-env +mantis -e encrypt-env ``` Decryption is easy like this: ```bash -mantis decrypt-env +mantis -e decrypt-env ``` When decrypting, mantis prompts user for confirmation. You can bypass that by forcing decryption which can be useful in CI/CD pipeline: ```bash -mantis decrypt-env --force +mantis -e decrypt-env --force ``` ## Usage @@ -135,14 +135,22 @@ mantis decrypt-env --force General usage of mantis-cli has this format: ```bash -mantis [OPTIONS] [ENVIRONMENT] COMMAND [ARGS]... +mantis [OPTIONS] COMMAND [ARGS]... [+ COMMAND [ARGS]...] +``` + +Use `+` to chain multiple commands: + +```bash +mantis -e production build + push + deploy ``` ### Options | Option | Description | |-----------------|---------------------------------------------------| +| --env, -e | Environment ID (e.g., stage, production) | | --mode, -m | Execution mode: remote (default), ssh, host | +| --dry-run, -n | Show commands without executing | | --version, -v | Show version and exit | | --help, -h | Show help message | @@ -233,20 +241,25 @@ Run `mantis commands` to see all available commands with their descriptions. ```bash mantis --version -mantis local encrypt-env -mantis stage build -mantis production logs web +mantis -e local encrypt-env +mantis -e stage build +mantis -e production logs web -# Run multiple commands at once -mantis stage build push deploy status logs +# Run multiple commands using + separator +mantis -e stage build + push + deploy +mantis -e stage build web api + push + deploy + status # Commands with arguments -mantis production deploy --dirty -mantis production manage migrate -mantis production pg-dump --data-only --table users +mantis -e production deploy --dirty +mantis -e production manage migrate +mantis -e production pg-dump --data-only --table users + +# Single connection mode (no environment needed) +mantis status +mantis deploy ``` -Check `mantis --help` for more details, or `mantis commands` for a full list of commands. +Check `mantis --help` for more details, or `mantis COMMAND --help` for command-specific help. ## Flow @@ -255,7 +268,7 @@ Check `mantis --help` for more details, or `mantis commands` for a full list of Once you define mantis config for your project and optionally create encryption key, you can build your docker images: ```bash -mantis build +mantis -e build ``` Mantis either uses `docker-compose --build` or `docker build` command depending on build tool defined in your config. @@ -266,7 +279,7 @@ Build image names use '_' as word separator. Built images needs to be pushed to your repository defined in compose file (you need to authenticate) ```bash -mantis push +mantis -e push ``` ### 3. Deployment @@ -274,7 +287,13 @@ mantis push Deployment to your remote server is being executed by calling simple command: ```bash -mantis deploy +mantis -e deploy +``` + +Or chain all steps together: + +```bash +mantis -e build + push + deploy ``` The deployment process consists of multiple steps: @@ -294,25 +313,25 @@ Docker container names use '-' as word separator (docker compose v2 convention). Once deployed, you can verify the container status: ```bash -mantis status +mantis -e status ``` list all docker networks: ```bash -mantis networks +mantis -e networks ``` and also check all container logs: ```bash -mantis logs +mantis -e logs ``` If you need to follow logs of a specific container, you can do it by passing container name to command: ```bash -mantis logs +mantis -e logs ``` ### 5. Another useful commands @@ -320,21 +339,21 @@ mantis logs Sometimes, instead of calling whole deployment process, you just need to call compose commands directly: ```bash -mantis up -mantis down -mantis restart -mantis stop -mantis kill -mantis start -mantis clean +mantis -e up +mantis -e down +mantis -e restart +mantis -e stop +mantis -e kill +mantis -e start +mantis -e clean ``` Commands over a single container: ```bash -mantis bash -mantis sh -mantis run +mantis -e bash +mantis -e sh +mantis -e run ``` ## Zero-downtime deployment diff --git a/mantis/app.py b/mantis/app.py index 694be0a..1b48ac3 100644 --- a/mantis/app.py +++ b/mantis/app.py @@ -18,7 +18,7 @@ mantis -e production deploy --dirty - mantis -e production build push deploy + mantis -e production build + push + deploy mantis -e prod manage migrate --fake @@ -49,9 +49,6 @@ # Commands that don't require environment (populated by @no_env_required decorator) NO_ENV_COMMANDS: set[str] = set() -# All registered command names (populated by @command decorator for argument parsing) -COMMAND_NAMES: set[str] = set() - # Cache hostname _hostname = socket.gethostname() @@ -145,11 +142,6 @@ def command( def decorator(func: Callable) -> Callable: cmd_name = name or func.__name__.replace('_', '-') - # Track command names for argument parsing - COMMAND_NAMES.add(cmd_name) - if shortcut: - COMMAND_NAMES.add(shortcut) - # Mark as no-env command if no_env: NO_ENV_COMMANDS.add(cmd_name) diff --git a/mantis/command_line.py b/mantis/command_line.py index e86f67b..c884e62 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -3,80 +3,86 @@ Mantis CLI - Docker deployment tool Usage: - mantis [OPTIONS] COMMAND [ARGS]... [COMMAND [ARGS]...] + mantis [OPTIONS] COMMAND [ARGS]... [+ COMMAND [ARGS]...] Examples: mantis -e production status mantis -e production deploy --dirty - mantis -e production build push deploy + mantis -e production build + push + deploy + mantis -e production build web api + push + deploy mantis status (single connection mode) mantis manage migrate """ import sys -from typing import List, Tuple, Set +from typing import List, Tuple import click import typer from mantis import VERSION -from mantis.app import app, state, COMMAND_NAMES, print_heading +from mantis.app import app, state from mantis.managers import get_manager # Import commands to register them with the app from mantis import commands # noqa: F401 +# Command separator for chaining +COMMAND_SEPARATOR = '+' -# Global options that take a value -OPTS_WITH_VALUES = {'-e', '--env', '-m', '--mode'} - -def split_args_by_commands( - args: List[str], - command_names: Set[str] -) -> Tuple[List[str], List[Tuple[str, List[str]]]]: +def split_args(args: List[str]) -> Tuple[List[str], List[List[str]]]: """ - Split argv into global options and (command, args) groups. + Split args into global options and command groups using '+' separator. - Input: ['-e', 'prod', 'build', 'web', 'push', 'deploy'] - Output: (['-e', 'prod'], [('build', ['web']), ('push', []), ('deploy', [])]) + Input: ['-e', 'prod', 'build', 'web', '+', 'push', '+', 'deploy'] + Output: (['-e', 'prod'], [['build', 'web'], ['push'], ['deploy']]) """ + # Split by separator + groups = [] + current = [] + + for arg in args: + if arg == COMMAND_SEPARATOR: + if current: + groups.append(current) + current = [] + else: + current.append(arg) + + if current: + groups.append(current) + + if not groups: + return [], [] + + # First group: separate global options from first command + first_group = groups[0] global_opts = [] - cmd_groups = [] - current_cmd = None - current_args = [] + cmd_start = 0 + # Global options are at the start and begin with '-' i = 0 - while i < len(args): - arg = args[i] - - if current_cmd is None: - # Still parsing global options - if arg.startswith('-'): - global_opts.append(arg) - # Consume value for options that take one - if arg in OPTS_WITH_VALUES and i + 1 < len(args): - i += 1 - global_opts.append(args[i]) - elif arg in command_names: - # First command found - current_cmd = arg - current_args = [] - # else: unexpected positional before command - will be handled by Typer + while i < len(first_group): + arg = first_group[i] + if arg.startswith('-'): + global_opts.append(arg) + # Handle options with values: -e prod, --env prod + if arg in ('-e', '--env', '-m', '--mode') and i + 1 < len(first_group): + i += 1 + global_opts.append(first_group[i]) + i += 1 else: - # After first command - if arg in command_names: - # New command - save previous and start new group - cmd_groups.append((current_cmd, current_args)) - current_cmd = arg - current_args = [] - else: - # Argument for current command - current_args.append(arg) - i += 1 - - # Don't forget last command - if current_cmd is not None: - cmd_groups.append((current_cmd, current_args)) + # First non-option is start of command + break + + # Remaining of first group is the first command + first_cmd = first_group[i:] if i < len(first_group) else [] + + # Build command groups + cmd_groups = [] + if first_cmd: + cmd_groups.append(first_cmd) + cmd_groups.extend(groups[1:]) return global_opts, cmd_groups @@ -135,7 +141,7 @@ def invoke_command(click_app, cmd_name: str, cmd_args: List[str], parent_ctx): def run(): - """Entry point with command-aware argument parsing.""" + """Entry point with command chaining support using '+' separator.""" args = sys.argv[1:] # No args - show help @@ -143,7 +149,7 @@ def run(): app() return - global_opts, cmd_groups = split_args_by_commands(args, COMMAND_NAMES) + global_opts, cmd_groups = split_args(args) # No commands found - delegate to Typer (handles --help, --version, errors) if not cmd_groups: @@ -161,18 +167,24 @@ def run(): # Handle --help: show help for first command if opts['help']: - sys.argv = [sys.argv[0]] + [cmd_groups[0][0], '--help'] + sys.argv = [sys.argv[0]] + cmd_groups[0][:1] + ['--help'] app() return - # Check if any command has --help in its args - delegate to Typer without initializing manager - for cmd_name, cmd_args in cmd_groups: - if '--help' in cmd_args or '-h' in cmd_args: - sys.argv = [sys.argv[0]] + [cmd_name, '--help'] + # Check if any command has --help in its args + for cmd_group in cmd_groups: + if '--help' in cmd_group or '-h' in cmd_group: + sys.argv = [sys.argv[0]] + cmd_group[:1] + ['--help'] app() return - # Initialize state (mirrors @app.callback behavior) + # Single command without chaining - delegate to Typer for normal flow + if len(cmd_groups) == 1: + sys.argv = [sys.argv[0]] + global_opts + cmd_groups[0] + app() + return + + # Multiple commands - manual invocation state._mode = opts['mode'] state._dry_run = opts['dry_run'] state._manager = get_manager(opts['env'], opts['mode'], dry_run=opts['dry_run']) @@ -180,15 +192,14 @@ def run(): # Get Click app from Typer click_app = typer.main.get_command(app) - # Create parent context for shared state - # Use resilient_parsing to prevent Click from triggering no_args_is_help + # Create parent context and invoke each command try: with click_app.make_context('mantis', [], resilient_parsing=True) as parent_ctx: - # Invoke each command in sequence - for cmd_name, cmd_args in cmd_groups: + for cmd_group in cmd_groups: + cmd_name = cmd_group[0] + cmd_args = cmd_group[1:] invoke_command(click_app, cmd_name, cmd_args, parent_ctx) except click.exceptions.Exit: - # Normal exit (e.g., from --help in command) pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fc10664 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Mantis CLI tests.""" diff --git a/tests/test_command_line.py b/tests/test_command_line.py new file mode 100644 index 0000000..09f2997 --- /dev/null +++ b/tests/test_command_line.py @@ -0,0 +1,249 @@ +"""Tests for command_line module - argument parsing and command chaining.""" +import pytest +import sys +from unittest.mock import patch, MagicMock + +from mantis.command_line import ( + split_args, + parse_global_options, + COMMAND_SEPARATOR, +) + + +class TestSplitArgs: + """Tests for split_args function.""" + + def test_chained_commands_simple(self): + """Test basic command chaining with + separator.""" + args = ['-e', 'stage', 'build', '+', 'push', '+', 'deploy'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'stage'] + assert cmd_groups == [['build'], ['push'], ['deploy']] + + def test_chained_commands_with_args(self): + """Test command chaining where first command has arguments.""" + args = ['-e', 'stage', 'build', 'web', 'api', '+', 'push', '+', 'deploy'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'stage'] + assert cmd_groups == [['build', 'web', 'api'], ['push'], ['deploy']] + + def test_single_command(self): + """Test single command without chaining.""" + args = ['-e', 'prod', 'status'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod'] + assert cmd_groups == [['status']] + + def test_single_command_with_args(self): + """Test single command with arguments.""" + args = ['-e', 'prod', 'build', 'web', 'api'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod'] + assert cmd_groups == [['build', 'web', 'api']] + + def test_command_with_options(self): + """Test command with its own options.""" + args = ['-e', 'prod', 'deploy', '--dirty'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod'] + assert cmd_groups == [['deploy', '--dirty']] + + def test_chained_commands_with_options(self): + """Test chained commands where commands have options.""" + args = ['-e', 'prod', 'build', '--no-cache', '+', 'deploy', '--dirty'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod'] + assert cmd_groups == [['build', '--no-cache'], ['deploy', '--dirty']] + + def test_help_flag(self): + """Test command with --help flag.""" + args = ['build', '--help'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == [] + assert cmd_groups == [['build', '--help']] + + def test_global_help_flag(self): + """Test global --help flag.""" + args = ['--help'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['--help'] + assert cmd_groups == [] + + def test_version_flag(self): + """Test --version flag.""" + args = ['--version'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['--version'] + assert cmd_groups == [] + + def test_dry_run_flag(self): + """Test -n/--dry-run flag.""" + args = ['-n', '-e', 'prod', 'deploy'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-n', '-e', 'prod'] + assert cmd_groups == [['deploy']] + + def test_mode_option(self): + """Test --mode option.""" + args = ['-e', 'prod', '-m', 'ssh', 'status'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod', '-m', 'ssh'] + assert cmd_groups == [['status']] + + def test_long_options(self): + """Test long option names.""" + args = ['--env', 'prod', '--mode', 'host', '--dry-run', 'status'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['--env', 'prod', '--mode', 'host', '--dry-run'] + assert cmd_groups == [['status']] + + def test_empty_args(self): + """Test empty arguments.""" + global_opts, cmd_groups = split_args([]) + + assert global_opts == [] + assert cmd_groups == [] + + def test_only_global_opts(self): + """Test only global options, no command.""" + args = ['-e', 'prod'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod'] + assert cmd_groups == [] + + def test_multiple_separators(self): + """Test handling of multiple consecutive separators.""" + args = ['build', '+', '+', 'push'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == [] + assert cmd_groups == [['build'], ['push']] + + def test_separator_at_end(self): + """Test separator at the end.""" + args = ['-e', 'prod', 'build', '+'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod'] + assert cmd_groups == [['build']] + + def test_shortcut_commands(self): + """Test shortcut command names.""" + args = ['-e', 'prod', 'b', '+', 'p', '+', 'd'] + global_opts, cmd_groups = split_args(args) + + assert global_opts == ['-e', 'prod'] + assert cmd_groups == [['b'], ['p'], ['d']] + + +class TestParseGlobalOptions: + """Tests for parse_global_options function.""" + + def test_environment_short(self): + """Test -e option.""" + opts = parse_global_options(['-e', 'production']) + + assert opts['env'] == 'production' + assert opts['mode'] == 'remote' + assert opts['dry_run'] is False + + def test_environment_long(self): + """Test --env option.""" + opts = parse_global_options(['--env', 'staging']) + + assert opts['env'] == 'staging' + + def test_mode_short(self): + """Test -m option.""" + opts = parse_global_options(['-m', 'ssh']) + + assert opts['mode'] == 'ssh' + + def test_mode_long(self): + """Test --mode option.""" + opts = parse_global_options(['--mode', 'host']) + + assert opts['mode'] == 'host' + + def test_dry_run_short(self): + """Test -n option.""" + opts = parse_global_options(['-n']) + + assert opts['dry_run'] is True + + def test_dry_run_long(self): + """Test --dry-run option.""" + opts = parse_global_options(['--dry-run']) + + assert opts['dry_run'] is True + + def test_version_short(self): + """Test -v option.""" + opts = parse_global_options(['-v']) + + assert opts['version'] is True + + def test_version_long(self): + """Test --version option.""" + opts = parse_global_options(['--version']) + + assert opts['version'] is True + + def test_help_short(self): + """Test -h option.""" + opts = parse_global_options(['-h']) + + assert opts['help'] is True + + def test_help_long(self): + """Test --help option.""" + opts = parse_global_options(['--help']) + + assert opts['help'] is True + + def test_all_options(self): + """Test all options combined.""" + opts = parse_global_options(['-e', 'prod', '-m', 'ssh', '-n']) + + assert opts['env'] == 'prod' + assert opts['mode'] == 'ssh' + assert opts['dry_run'] is True + assert opts['help'] is False + assert opts['version'] is False + + def test_defaults(self): + """Test default values.""" + opts = parse_global_options([]) + + assert opts['env'] is None + assert opts['mode'] == 'remote' + assert opts['dry_run'] is False + assert opts['help'] is False + assert opts['version'] is False + + def test_unknown_options_ignored(self): + """Test that unknown options are ignored.""" + opts = parse_global_options(['--unknown', '-x', '-e', 'prod']) + + assert opts['env'] == 'prod' + + +class TestCommandSeparator: + """Tests for command separator constant.""" + + def test_separator_is_plus(self): + """Verify the separator is '+'.""" + assert COMMAND_SEPARATOR == '+' From 285169523d71c5c373018393a27f2b5b087b79b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Thu, 8 Jan 2026 19:19:19 +0100 Subject: [PATCH 21/22] refactoring --- mantis/command_line.py | 23 ++++++----------------- tests/test_command_line.py | 30 +----------------------------- 2 files changed, 7 insertions(+), 46 deletions(-) diff --git a/mantis/command_line.py b/mantis/command_line.py index c884e62..22d4d6f 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -58,7 +58,6 @@ def split_args(args: List[str]) -> Tuple[List[str], List[List[str]]]: # First group: separate global options from first command first_group = groups[0] global_opts = [] - cmd_start = 0 # Global options are at the start and begin with '-' i = 0 @@ -88,13 +87,11 @@ def split_args(args: List[str]) -> Tuple[List[str], List[List[str]]]: def parse_global_options(global_opts: List[str]) -> dict: - """Parse global options into a dict.""" + """Parse global options into a dict. Only used for multi-command chaining.""" result = { 'env': None, 'mode': 'remote', 'dry_run': False, - 'help': False, - 'version': False, } i = 0 @@ -109,12 +106,6 @@ def parse_global_options(global_opts: List[str]) -> dict: elif opt in ('-n', '--dry-run'): result['dry_run'] = True i += 1 - elif opt in ('-v', '--version'): - result['version'] = True - i += 1 - elif opt in ('-h', '--help'): - result['help'] = True - i += 1 else: i += 1 @@ -157,16 +148,13 @@ def run(): app() return - # Parse global options - opts = parse_global_options(global_opts) - - # Handle --version - if opts['version']: + # Handle --version early + if '--version' in global_opts or '-v' in global_opts: print(f"Mantis v{VERSION}") return # Handle --help: show help for first command - if opts['help']: + if '--help' in global_opts or '-h' in global_opts: sys.argv = [sys.argv[0]] + cmd_groups[0][:1] + ['--help'] app() return @@ -184,7 +172,8 @@ def run(): app() return - # Multiple commands - manual invocation + # Multiple commands - parse options and initialize state manually + opts = parse_global_options(global_opts) state._mode = opts['mode'] state._dry_run = opts['dry_run'] state._manager = get_manager(opts['env'], opts['mode'], dry_run=opts['dry_run']) diff --git a/tests/test_command_line.py b/tests/test_command_line.py index 09f2997..bb866bd 100644 --- a/tests/test_command_line.py +++ b/tests/test_command_line.py @@ -150,7 +150,7 @@ def test_shortcut_commands(self): class TestParseGlobalOptions: - """Tests for parse_global_options function.""" + """Tests for parse_global_options function (used only for multi-command chaining).""" def test_environment_short(self): """Test -e option.""" @@ -190,30 +190,6 @@ def test_dry_run_long(self): assert opts['dry_run'] is True - def test_version_short(self): - """Test -v option.""" - opts = parse_global_options(['-v']) - - assert opts['version'] is True - - def test_version_long(self): - """Test --version option.""" - opts = parse_global_options(['--version']) - - assert opts['version'] is True - - def test_help_short(self): - """Test -h option.""" - opts = parse_global_options(['-h']) - - assert opts['help'] is True - - def test_help_long(self): - """Test --help option.""" - opts = parse_global_options(['--help']) - - assert opts['help'] is True - def test_all_options(self): """Test all options combined.""" opts = parse_global_options(['-e', 'prod', '-m', 'ssh', '-n']) @@ -221,8 +197,6 @@ def test_all_options(self): assert opts['env'] == 'prod' assert opts['mode'] == 'ssh' assert opts['dry_run'] is True - assert opts['help'] is False - assert opts['version'] is False def test_defaults(self): """Test default values.""" @@ -231,8 +205,6 @@ def test_defaults(self): assert opts['env'] is None assert opts['mode'] == 'remote' assert opts['dry_run'] is False - assert opts['help'] is False - assert opts['version'] is False def test_unknown_options_ignored(self): """Test that unknown options are ignored.""" From 1653221fcd9094d900c93c53521d774d531d2e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Telepovsk=C3=BD?= Date: Thu, 8 Jan 2026 19:20:58 +0100 Subject: [PATCH 22/22] github actions for tests --- .github/workflows/tests.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..292e9c2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: [master, dev] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest + + - name: Run tests + run: pytest tests/ -v