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 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 b488934..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 @@ -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 -e encrypt-env ``` Decryption is easy like this: ```bash -mantis --decrypt-env +mantis -e 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 -e decrypt-env --force ``` ## Usage @@ -135,27 +135,40 @@ mantis --decrypt-env:force General usage of mantis-cli has this format: ```bash -mantis [--mode=remote|ssh|host] [environment] --command[:params] +mantis [OPTIONS] COMMAND [ARGS]... [+ COMMAND [ARGS]...] ``` -### Modes +Use `+` to chain multiple commands: -Mantis can operate in 3 different modes depending on a way it connects to remote machhine +```bash +mantis -e production build + push + deploy +``` +### Options -#### Remote mode ```--mode=remote``` +| 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 | -Runs commands remotely from local machine using DOCKER_HOST or DOCKER_CONTEXT (default) +### Modes -#### SSH mode ```--mode=ssh``` +Mantis can operate in 3 different modes depending on how it connects to remote machine: -Connects to host via ssh and run all mantis commands on remote machine directly (nantis-cli needs to be installed on server) +#### Remote mode `--mode=remote` +Runs commands remotely from local machine using DOCKER_HOST or DOCKER_CONTEXT (default) -#### Host mode ```--mode=host``` +#### SSH mode `--mode=ssh` -Runs mantis on host machine directly without invoking connection (used as proxy for ssh mode) +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` + +Runs mantis on host machine directly without invoking connection (used as proxy for ssh mode) ### Environments @@ -164,79 +177,89 @@ 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 - -# you can also run multiple commands at once -mantis stage --build --push --deploy -s -l +mantis -e local encrypt-env +mantis -e stage build +mantis -e production logs web + +# Run multiple commands using + separator +mantis -e stage build + push + deploy +mantis -e stage build web api + push + deploy + status + +# Commands with arguments +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. +Check `mantis --help` for more details, or `mantis COMMAND --help` for command-specific help. ## Flow @@ -245,10 +268,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 -e 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 +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 @@ -264,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: @@ -284,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 @@ -310,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:container-name -mantis --sh:container-name -mantis --run:params +mantis -e bash +mantis -e sh +mantis -e 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/__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..1b48ac3 --- /dev/null +++ b/mantis/app.py @@ -0,0 +1,194 @@ +"""Mantis CLI app setup and shared state.""" +import socket +from functools import wraps +from typing import Optional, List, Callable + +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}, + add_completion=True, +) + +# 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._dry_run = False + self._heading_printed = False + self._current_command = None + + def _ensure_ready(self): + """Print heading and validate environment.""" + if not self._heading_printed: + print_heading(self._manager, self._mode) + self._heading_printed = True + + 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.""" + self._ensure_ready() + return getattr(self._manager, name) + + +state = State() + + +def print_heading(manager, mode: str): + """Print the heading with environment and connection info.""" + console = Console() + + 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") + + if manager.dry_run: + heading.append(" ") + heading.append("[DRY-RUN]", style="bold yellow") + + console.print(heading) + + +# ============================================================================= +# 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): + if value: + typer.echo(f"Mantis v{VERSION}") + raise typer.Exit() + + +@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 b559c2f..22d4d6f 100644 --- a/mantis/command_line.py +++ b/mantis/command_line.py @@ -1,215 +1,196 @@ #!/usr/bin/env python -import os +""" +Mantis CLI - Docker deployment tool + +Usage: + 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 web api + push + deploy + mantis status (single connection mode) + mantis manage migrate +""" import sys -import inspect +from typing import List, Tuple -from rich.console import Console -from rich.table import Table -from rich.text import Text +import click +import typer 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': {} - } +from mantis.app import app, state +from mantis.managers import get_manager - 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) +# Import commands to register them with the app +from mantis import commands # noqa: F401 - return d +# Command separator for chaining +COMMAND_SEPARATOR = '+' -def run(): - arguments = sys.argv.copy() - arguments.pop(0) - - # check params - params = parse_args(arguments) - - # version info - version_info = f'Mantis v{VERSION}' - - if params['commands'] == ['--version']: - return print(version_info) - - if params['commands'] == ['--help']: - return help() - - # get params - environment_id = params['environment_id'] - commands = params['commands'] - mode = params['settings'].get('mode', 'remote') - - # get manager - manager = get_manager(environment_id, mode) - - if len(params['commands']) == 0: - CLI.error('Missing commands. Check mantis --help for more information.') - - if mode not in ['remote', 'ssh', 'host']: - CLI.error('Incorrect mode. Check mantis --help for more information.') - - hostname = os.popen('hostname').read().rstrip("\n") - - # check config settings - settings_config = params['settings'].get('config', None) - - 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 - ) - - console = Console() - - 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) - - 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): +def split_args(args: List[str]) -> Tuple[List[str], List[List[str]]]: """ - Extract commands from a class for help display. - Returns list of tuples: (command_str, description) - """ - 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('_', '-') - - # Get the method signature - signature = inspect.signature(method) - - # Parameters (skip 'self') - parameters = [p for p in signature.parameters.keys() if p != 'self'] - - # Check if parameters are optional - params_are_optional = True - - for param_name, param in signature.parameters.items(): - if param_name == 'self': - continue - if param.default == inspect.Parameter.empty: - params_are_optional = False + Split args into global options and command groups using '+' separator. - # Build command string - command = f"--{command}" - params_str = "" - - if parameters: - if not params_are_optional: - params_str += '[' - - params_str += ':' - - params_str += ','.join(parameters) - - if not params_are_optional: - params_str += ']' - - docs = method.__doc__ or '' + 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 = [] + + # Global options are at the start and begin with '-' + i = 0 + 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: + # First non-option is start of command + break - commands.append((f"{command}{params_str}", docs.strip())) + # Remaining of first group is the first command + first_cmd = first_group[i:] if i < len(first_group) else [] - return commands + # Build command groups + cmd_groups = [] + if first_cmd: + cmd_groups.append(first_cmd) + cmd_groups.extend(groups[1:]) + return global_opts, cmd_groups -def help(): - print(f'\nUsage:\n\ - mantis [--mode=remote|ssh|host] [environment] --command[:params]') - 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)') +def parse_global_options(global_opts: List[str]) -> dict: + """Parse global options into a dict. Only used for multi-command chaining.""" + result = { + 'env': None, + 'mode': 'remote', + 'dry_run': False, + } - 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").') + 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 + else: + i += 1 - console = Console() + return result - # Base commands - print(f'\nCommands:') - table = Table(show_header=True, header_style="bold") - table.add_column("Command", style="cyan") - table.add_column("Description") - for command, description in get_class_commands(BaseManager, exclude_from=AbstractManager): - table.add_row(command, description) +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 - console.print(table) + cmd = click_app.get_command(parent_ctx, cmd_name) + if cmd is None: + CLI.error(f"Unknown command: {cmd_name}") - # Extension commands - extensions = [ - ('Django', Django), - ('Nginx', Nginx), - ('Postgres', Postgres), - ] + state._current_command = cmd_name - 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") + # 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 - for command, description in ext_commands: - ext_table.add_row(command, description) - console.print(ext_table) +def run(): + """Entry point with command chaining support using '+' separator.""" + args = sys.argv[1:] + + # No args - show help + if not args: + app() + return + + global_opts, cmd_groups = split_args(args) + + # No commands found - delegate to Typer (handles --help, --version, errors) + if not cmd_groups: + sys.argv = [sys.argv[0]] + global_opts + app() + return + + # 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 '--help' in global_opts or '-h' in global_opts: + sys.argv = [sys.argv[0]] + cmd_groups[0][:1] + ['--help'] + app() + return + + # 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 + + # 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 - 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']) + + # Get Click app from Typer + click_app = typer.main.get_command(app) + + # Create parent context and invoke each command + try: + with click_app.make_context('mantis', [], resilient_parsing=True) as parent_ctx: + 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: + pass + + +if __name__ == "__main__": + run() 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..e543c80 --- /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 + + +@command(panel="Compose") +def up( + params: Optional[List[str]] = typer.Argument(None, help="Compose up parameters"), +): + """Calls compose up""" + state.up(params=params) + + +@command(panel="Compose") +def down( + params: Optional[List[str]] = typer.Argument(None, help="Compose down parameters"), +): + """Calls compose down""" + state.down(params=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(params=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..d99c37b --- /dev/null +++ b/mantis/commands/containers.py @@ -0,0 +1,120 @@ +"""Container commands: logs, start, stop, kill, remove, bash, sh, exec, etc.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state + + +@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(containers=containers) + + +@command(panel="Containers") +def start( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to start"), +): + """Starts containers""" + state.start(containers=containers) + + +@command(panel="Containers") +def kill( + containers: Optional[List[str]] = typer.Argument(None, help="Containers to kill"), +): + """Kills containers""" + 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(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"), +): + """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(container=container, cmd=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(container=container, cmd=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..b5d001d --- /dev/null +++ b/mantis/commands/core.py @@ -0,0 +1,43 @@ +"""Core commands: status, deploy, clean, upload.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state + + +@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"), + 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, 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") +def clean( + params: Optional[List[str]] = typer.Argument(None, help="Clean parameters"), +): + """Clean images, containers, networks""" + state.clean(params=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..12470f6 --- /dev/null +++ b/mantis/commands/django.py @@ -0,0 +1,27 @@ +"""Django extension commands: shell, manage, send-test-email.""" +from typing import Optional, List + +import typer + +from mantis.app import command, state + + +@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""" + state.manage(cmd=cmd, args=args) + + +@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..9ce8349 --- /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 + + +@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(services=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(services=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(services=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) diff --git a/mantis/config.py b/mantis/config.py new file mode 100644 index 0000000..d96fbce --- /dev/null +++ b/mantis/config.py @@ -0,0 +1,192 @@ +import os +import sys +import json +from json.decoder import JSONDecodeError +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +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) + + if env_path and env_path != '': + CLI.info(f'Mantis config defined by environment variable $MANTIS_CONFIG: {env_path}') + return env_path + + CLI.info('Environment variable $MANTIS_CONFIG not found. Looking for file mantis.json...') + paths = [str(p) for p in Path('.').rglob('mantis.json')] + + # Sort for consistent ordering + paths.sort() + + # Count found mantis files + total_mantis_files = len(paths) + + # No mantis file found + if total_mantis_files == 0: + DEFAULT_PATH = 'configs/mantis.json' + CLI.info(f'mantis.json file not found. Using default value: {DEFAULT_PATH}') + return DEFAULT_PATH + + # Single mantis file found + if total_mantis_files == 1: + CLI.info(f'Found 1 mantis.json file: {paths[0]}') + return paths[0] + + # Multiple mantis files found + CLI.info(f'Found {total_mantis_files} mantis.json files:') + + console = Console() + table = Table(show_header=True, header_style="bold") + table.add_column("#", style="cyan") + 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) + + # 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 + colorful_connections = [] + for connection in connections: + # 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)) + + # Dim path if no environment match + 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 + 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: {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: {get_config_dir(selected_path)}') + return selected_path + + CLI.danger(f'[0] Exit now and define $MANTIS_CONFIG environment variable') + + path_index = None + while path_index is None: + path_index = input('Define which one to use: ') + if not path_index.isdigit() or int(path_index) > len(paths): + path_index = None + else: + path_index = int(path_index) + + if path_index == 0: + sys.exit(0) + + return paths[path_index - 1] + + +def find_keys_only_in_config(config, template, parent_key=""): + differences = [] + + # Iterate over keys in config + for key in config: + # Construct the full key path + full_key = parent_key + "." + key if parent_key else key + + # Check if key exists in template + if key not in template: + differences.append(full_key) + else: + # Recursively compare nested dictionaries + if isinstance(config[key], dict) and isinstance(template[key], dict): + nested_differences = find_keys_only_in_config(config[key], template[key], parent_key=full_key) + differences.extend(nested_differences) + + return differences + + +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.') + sys.exit(1) + + with open(config_file, "r") as config: + try: + return json.load(config) + except JSONDecodeError as e: + CLI.error(f"Failed to load config from file {config_file}: {e}") + + +def load_template_config() -> dict: + template_path = Path(__file__).parent / 'mantis.tpl' + return load_config(str(template_path)) + + +def check_config(config): + """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 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 86094c1..7dc3efc 100644 --- a/mantis/environment.py +++ b/mantis/environment.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from mantis.helpers import CLI @@ -21,17 +22,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)) + 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 = [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): """ @@ -39,32 +42,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 +78,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/extensions/django.py b/mantis/extensions/django.py index 8215ddc..d6a75b9 100644 --- a/mantis/extensions/django.py +++ b/mantis/extensions/django.py @@ -11,29 +11,25 @@ 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): - """ - 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 - """ + 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 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') diff --git a/mantis/extensions/nginx.py b/mantis/extensions/nginx.py index b510bfd..01bfd6d 100644 --- a/mantis/extensions/nginx.py +++ b/mantis/extensions/nginx.py @@ -9,8 +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') diff --git a/mantis/extensions/postgres.py b/mantis/extensions/postgres.py index ce061db..42a1542 100644 --- a/mantis/extensions/postgres.py +++ b/mantis/extensions/postgres.py @@ -11,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' @@ -38,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}' @@ -66,13 +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) diff --git a/mantis/helpers.py b/mantis/helpers.py index 1d78ed5..9a9d925 100644 --- a/mantis/helpers.py +++ b/mantis/helpers.py @@ -1,4 +1,9 @@ +import select +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 @@ -15,7 +20,27 @@ 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 + @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'): @@ -55,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/logic.py b/mantis/logic.py deleted file mode 100644 index 5cb7a16..0000000 --- a/mantis/logic.py +++ /dev/null @@ -1,222 +0,0 @@ -import os -import json -from json.decoder import JSONDecodeError -from os.path import dirname, normpath, abspath - -from rich.console import Console -from rich.table import Table - -from mantis.helpers import CLI, import_string - - -def find_config(environment_id=None): - env_path = os.environ.get('MANTIS_CONFIG', None) - - if env_path and env_path != '': - CLI.info(f'Mantis config defined by environment variable $MANTIS_CONFIG: {env_path}') - 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') - - # Remove empty strings - paths = list(filter(None, paths)) - - # Count found mantis files - total_mantis_files = len(paths) - - # No mantis file found - if total_mantis_files == 0: - DEFAULT_PATH = 'configs/mantis.json' - CLI.info(f'mantis.json file not found. Using default value: {DEFAULT_PATH}') - return DEFAULT_PATH - - # Single mantis file found - if total_mantis_files == 1: - CLI.info(f'Found 1 mantis.json file: {paths[0]}') - return paths[0] - - # Multiple mantis files found - CLI.info(f'Found {total_mantis_files} mantis.json files:') - - console = Console() - table = Table(show_header=True, header_style="bold") - table.add_column("#", style="cyan") - table.add_column("Path") - table.add_column("Connections") - - for index, path in enumerate(paths): - config = load_config(path) - - # Check for single connection mode - single_connection = config.get('connection') - - if single_connection: - # Single connection mode - display the connection string - connections_display = '[green](single)[/green]' - else: - # Multi-environment mode - display connection keys - connections = config.get('connections', {}).keys() - - # TODO: get project names from compose files - - colorful_connections = [] - for connection in connections: - color = 'green' if connection == environment_id else 'yellow' - colorful_connections.append(f'[{color}]{connection}[/{color}]') - connections_display = ', '.join(colorful_connections) - - table.add_row(str(index + 1), normpath(dirname(path)), connections_display) - - console.print(table) - CLI.danger(f'[0] Exit now and define $MANTIS_CONFIG environment variable') - - path_index = None - while path_index is None: - path_index = input('Define which one to use: ') - if not path_index.isdigit() or int(path_index) > len(paths): - path_index = None - else: - path_index = int(path_index) - - if path_index == 0: - exit() - - return paths[path_index - 1] - - -def find_keys_only_in_config(config, template, parent_key=""): - differences = [] - - # Iterate over keys in config - for key in config: - # Construct the full key path - full_key = parent_key + "." + key if parent_key else key - - # Check if key exists in template - if key not in template: - differences.append(full_key) - else: - # Recursively compare nested dictionaries - if isinstance(config[key], dict) and isinstance(template[key], dict): - nested_differences = find_keys_only_in_config(config[key], template[key], parent_key=full_key) - differences.extend(nested_differences) - - return differences - - -def load_config(config_file): - if not os.path.exists(config_file): - 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 {} - - with open(config_file, "r") as config: - try: - return json.load(config) - except JSONDecodeError as e: - 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 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') - CLI.error( - f"Config file validation failed. Unknown config keys: {config_keys_only}. Check {template_link} for available attributes.") - - -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 - - -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/mantis/managers.py b/mantis/managers.py index 7a0ff45..4056056 100644 --- a/mantis/managers.py +++ b/mantis/managers.py @@ -1,20 +1,24 @@ +import asyncio import json import os +import subprocess +import sys import time import yaml from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor from datetime import datetime -from os import path -from os.path import normpath +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 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): @@ -23,9 +27,10 @@ class AbstractManager(object): """ environment_id = None - def __init__(self, config_file=None, environment_id=None, mode='remote'): + 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 # config file self.config_file = config_file @@ -45,18 +50,18 @@ def __init__(self, config_file=None, environment_id=None, mode='remote'): 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], @@ -64,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 @@ -92,13 +97,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}') @@ -112,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 '' @@ -129,12 +136,12 @@ 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 = 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,16 +166,16 @@ 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): + def init_environment(self) -> None: if self.single_connection_mode: # Single connection mode: no environment_id required self.env = Environment( @@ -180,11 +187,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,16 +212,15 @@ 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() - 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. @@ -223,9 +228,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: @@ -239,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 @@ -286,38 +291,45 @@ 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: + CLI.warning(f'[DRY-RUN] {command}') + return + error_message = "Error during running command '%s'" % 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): + 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}' if return_output: - return os.popen(cmd).read() + 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 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]) @@ -328,7 +340,44 @@ def docker_compose(self, command, return_output=False, use_connection=True): use_connection=use_connection ) - def get_container_project(self, container): + def run_parallel(self, commands: List[str], description: str = "Running") -> List[Any]: + """ + 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: str) -> Optional[str]: """ Prints project name of given container :param container: container name @@ -342,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') @@ -372,24 +423,24 @@ 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 """ - 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) 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 """ @@ -401,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) """ @@ -467,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) """ @@ -538,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 """ @@ -548,7 +599,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'): @@ -558,20 +609,20 @@ 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 # 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 """ @@ -590,7 +641,6 @@ def create_context(self): host = f'{protocol}://{username}@{host_address}:{port}' else: CLI.error('Invalid protocol') - exit() endpoint = f'host={host}' @@ -608,20 +658,19 @@ def create_context(self): if input("Confirm? (Y)es/(N)o: ").lower() != 'y': CLI.error('Canceled') - exit() # create context 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 """ @@ -629,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 """ @@ -657,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 """ @@ -665,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) """ @@ -677,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 """ @@ -690,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 """ @@ -727,6 +776,10 @@ def healthcheck(self, container=None): 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) @@ -736,19 +789,20 @@ 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...') 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'] @@ -768,6 +822,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 '' @@ -775,16 +833,20 @@ 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']) + + cmd = f"{docker_connection} docker build {context} {build_args} {args} {platform} {cache_from} -t {image} -f {dockerfile} {params}" + build_commands.append(cmd.strip()) - # Build service using docker - self.docker(f"build {context} {build_args} {args} {platform} {cache_from} -t {image} -f {dockerfile} {params}", - use_connection=False) + # 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)}') - def project_services(self): + def project_services(self) -> Dict[str, List[str]]: """ Returns project names by compose files """ @@ -799,7 +861,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(): @@ -808,7 +870,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 """ @@ -825,7 +887,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 """ @@ -854,27 +916,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 """ @@ -891,12 +955,12 @@ 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...') - def restart(self, service=None): + def restart(self, service: Optional[str] = None) -> None: """ Restarts all containers by calling compose down and up """ @@ -921,14 +985,18 @@ 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, 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...') if dirty: - CLI.warning('...but dirty (no zero-downtime, without cleaning)') + CLI.warning('...but dirty (no zero-downtime, without cleaning)') self.upload() self.pull() @@ -936,10 +1004,19 @@ def deploy(self, dirty=False): is_running = len(self.get_containers(only_running=True)) != 0 if is_running and not dirty: - self.zero_downtime() + 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 - scale_param = '' + scale_param: List[str] = [] if is_running: scales = {} for service in self.services(): @@ -950,25 +1027,29 @@ 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() if not dirty: self.clean() - def zero_downtime(self, service=None): + CLI.success('Deployment complete!') + + 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) @@ -977,7 +1058,7 @@ def zero_downtime(self, service=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 @@ -985,9 +1066,46 @@ def zero_downtime(self, service=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() @@ -1015,7 +1133,126 @@ def zero_downtime(self, service=None): # reload webserver self.try_to_reload_webserver() - def remove_suffixes(self, prefix=''): + 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) """ @@ -1044,7 +1281,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 """ @@ -1064,10 +1301,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) """ @@ -1076,13 +1313,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: Optional[List[str]] = None) -> 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) @@ -1090,13 +1328,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: Optional[List[str]] = None) -> 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) @@ -1104,13 +1343,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: Optional[List[str]] = None) -> 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) @@ -1118,58 +1358,71 @@ def start(self, params=''): 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, params=''): + def remove(self, containers: Optional[List[str]] = None, force: bool = False) -> None: """ - 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 + 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 """ 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 """ @@ -1241,7 +1494,7 @@ def status(self): console.print(containers_table) - def networks(self): + def networks(self) -> None: """ Prints docker networks """ @@ -1265,7 +1518,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 """ @@ -1279,7 +1532,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 """ @@ -1287,14 +1540,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') @@ -1302,25 +1555,25 @@ 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): + 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}') - 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 """ @@ -1332,7 +1585,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 """ @@ -1345,7 +1598,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 """ @@ -1362,9 +1615,9 @@ def get_deploy_replicas(self, service): return replicas - def backup_volume(self, volume): + def backup_volume(self, volume: str) -> None: # backups folder - backup_path = os.getcwd() + '/backups/' + backup_path = str(Path.cwd() / 'backups') # Get current date, time and timezone name current_datetime = datetime.now() @@ -1379,9 +1632,9 @@ 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 = os.getcwd() + '/backups/' + backup_path = str(Path.cwd() / 'backups') command = f'run --rm \ -v {volume}:/{volume} \ @@ -1390,3 +1643,83 @@ def restore_volume(self, volume, file): tar -xzvf /backup/{file}' self.docker(command) + + +def get_extension_classes(extensions: List[str]) -> List[type]: + extension_classes: List[type] = [] + + # 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 resolve_environment(environment_id: Optional[str], config: Dict[str, Any]) -> Optional[str]: + """ + 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: Optional[str], mode: str, dry_run: bool = False) -> BaseManager: + # 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') + + # 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, dry_run=dry_run) + + # 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/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 59cbc46..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'], + install_requires=['cffi', 'cryptography', 'pycryptodome', 'pydantic', 'PyYAML', 'rich', 'typer'], entry_points={ 'console_scripts': ['mantis=mantis.command_line:run'], }, 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..bb866bd --- /dev/null +++ b/tests/test_command_line.py @@ -0,0 +1,221 @@ +"""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 (used only for multi-command chaining).""" + + 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_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 + + 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 + + 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 == '+'