#!/usr/bin/env python3 """Invoke tasks — замена Taskfile.yml""" import os import subprocess import sys from invoke.context import Context from invoke.exceptions import Exit from invoke.tasks import task HOSTS_FILE = "production.yml" VARS_FILE = "group_vars/all/main.yml" AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia" # --- Базовый набор: единый интерфейс команд во всех репозиториях --- # (набор и сигнатуры команд общие; реализация может отличаться по месту) def _yq(query: str) -> str: result = subprocess.run( ["yq", query, HOSTS_FILE], capture_output=True, text=True, check=True ) return result.stdout.strip() def _remote_user() -> str: return _yq(".ungrouped.hosts.server.ansible_user") def _remote_host() -> str: return _yq(".ungrouped.hosts.server.ansible_host") def _rest_args() -> list[str]: """Возвращает аргументы после '--' из sys.argv""" try: return sys.argv[sys.argv.index("--") + 1 :] except ValueError: return [] def _resolve_playbook(name: str) -> str: candidates = [name, f"{name}.yml", f"playbook-{name}.yml"] for candidate in candidates: if os.path.isfile(candidate): return candidate raise Exit( f"Плейбук для '{name}' не найден. Проверял: {', '.join(candidates)}", code=1 ) @task def install_roles(ctx: Context) -> None: """Установить ansible-galaxy roles""" ctx.run("uv run ansible-galaxy role install --role-file requirements.yml --force") @task def pl(ctx: Context) -> None: """Запустить плейбуки по имени: inv pl -- [name ...]""" names = _rest_args() if not names: raise Exit("Укажи хотя бы один плейбук: inv pl -- [name ...]", code=1) playbooks = [_resolve_playbook(name) for name in names] ctx.run( f"uv run ansible-playbook -i {HOSTS_FILE} --diff {' '.join(playbooks)}", pty=True, ) @task def ssh(ctx: Context) -> None: """SSH на удалённый сервер""" ctx.run(f"ssh {_remote_user()}@{_remote_host()}", pty=True) @task def zj(ctx: Context) -> None: """Запуск zellij на удалённом сервере""" ctx.run( f"ssh {_remote_user()}@{_remote_host()} -t zellij attach --create main", pty=True, ) @task def mc(ctx: Context) -> None: """Midnight Commander: слева локальная директория, справа удалённая домашняя""" ctx.run(f"mc . sh://{_remote_user()}@{_remote_host()}", pty=True) @task def btop(ctx: Context) -> None: """Запустить btop на удалённом сервере""" ctx.run(f"ssh {_remote_user()}@{_remote_host()} -t btop", pty=True) @task def encrypt(ctx: Context, file: str) -> None: """Зашифровать файлы через ansible-vault""" ctx.run(f"uv run ansible-vault encrypt {file}") @task def decrypt(ctx: Context, file: str) -> None: """Расшифровать файлы через ansible-vault""" ctx.run(f"uv run ansible-vault decrypt {file}") @task(aliases=["ee"]) def edit_encrypted(ctx: Context, path: str) -> None: """Редактировать vault-файл через ansible-vault edit (без расшифровки на диск)""" # Намеренно не через ctx.run: invoke заводит свой pty, а внешний терминал # переводит лишь в cbreak — ixon остаётся включённым, и Ctrl+S/Ctrl+Q съедает # драйвер терминала как XOFF/XON, до редактора они не доходят. subprocess # отдаёт редактору настоящий tty, и его raw-режим гасит ixon сам. result = subprocess.run(["uv", "run", "ansible-vault", "edit", path]) if result.returncode != 0: raise Exit( f"ansible-vault edit: код возврата {result.returncode}", code=result.returncode, ) # --- Синхронизация общих ролей с каноном ansible-roles --- # Блок идентичен во всех репозиториях; различается только SYNCED_ROLES. # Путь к канону — сосед по файловой системе (../ansible-roles/roles), # поэтому не зависит от текущей директории и одинаков везде. SHARED_ROLES_DIR = os.path.normpath( os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "ansible-roles", "roles" ) ) SYNCED_ROLES = ["eget", "app_image"] def _require_canon() -> None: if not os.path.isdir(SHARED_ROLES_DIR): raise Exit(f"Канон не найден: {SHARED_ROLES_DIR}", code=1) def _roles_to_sync() -> list[str]: """Роли для синхронизации: из '-- ...' или все из SYNCED_ROLES.""" requested = _rest_args() if not requested: return SYNCED_ROLES unknown = [r for r in requested if r not in SYNCED_ROLES] if unknown: raise Exit(f"Роли не в подписке SYNCED_ROLES: {', '.join(unknown)}", code=1) return requested @task def roles_status(ctx: Context) -> None: """Сравнить подписанные роли с каноном ansible-roles""" _require_canon() drift = False for role in SYNCED_ROLES: canon = os.path.join(SHARED_ROLES_DIR, role) if not os.path.isdir(canon): print(f"{role}: missing in shared") drift = True elif subprocess.run(["diff", "-rq", f"roles/{role}", canon]).returncode != 0: print(f"{role}: differs") drift = True else: print(f"{role}: ok") if drift: raise Exit("Есть расхождения с каноном", code=1) @task def roles_pull(ctx: Context) -> None: """Канон → репозиторий: inv roles-pull [-- ...]""" _require_canon() for role in _roles_to_sync(): src = os.path.join(SHARED_ROLES_DIR, role) if not os.path.isdir(src): raise Exit(f"Роль '{role}' отсутствует в каноне: {src}", code=1) os.makedirs(f"roles/{role}", exist_ok=True) ctx.run(f"rsync -a -i --delete {src}/ roles/{role}/") @task def roles_push(ctx: Context) -> None: """Репозиторий → канон, с подтверждением при расхождении: inv roles-push [-- ...]""" _require_canon() for role in _roles_to_sync(): local = f"roles/{role}" canon = os.path.join(SHARED_ROLES_DIR, role) if not os.path.isdir(local): raise Exit(f"Локальной роли нет: {local}", code=1) differs = ( os.path.isdir(canon) and subprocess.run(["diff", "-rq", local, canon]).returncode != 0 ) if differs: # Защита от затирания более свежего канона устаревшей копией. subprocess.run(["diff", "-r", canon, local]) answer = input( f"Канон '{role}' отличается. Перезаписать его копией из этого репо? [y/N] " ) if answer.strip().lower() != "y": print(f"{role}: пропущено") continue os.makedirs(canon, exist_ok=True) ctx.run(f"rsync -a -i --delete {local}/ {canon}/") # --- Специфичные для этого репозитория задачи --- def _application_dir() -> str: """Чтение application_dir: сначала из inventory (override), затем из group_vars.""" inv_value = _yq('.ungrouped.hosts.server.application_dir // ""') if inv_value: return inv_value result = subprocess.run( ["yq", ".application_dir", VARS_FILE], capture_output=True, text=True, check=True, ) value = result.stdout.strip() if not value or value == "null": raise Exit( f"application_dir не определён ни в inventory, ни в {VARS_FILE}", code=1 ) return value @task(aliases=["login"]) def login_as_app(ctx: Context, app: str) -> None: """SSH и переключиться на пользователя приложения: inv login gitea""" # sudo -i: login shell, -u: от имени пользователя # bash -i: интерактивный режим (job control), -l: login (читает профиль) app_dir = f"{_application_dir()}/{app}" subprocess.run( f"""ssh {_remote_user()}@{_remote_host()} -t 'sudo -iu {app} bash -c "cd {app_dir} && exec bash -il"'""", shell=True, ) @task def authelia_cli(ctx: Context, args: str = "") -> None: """Запустить authelia CLI в docker""" ctx.run(f"{AUTHELIA_DOCKER} {args}") @task def authelia_validate_config(ctx: Context) -> None: """Отрендерить конфиг authelia из шаблона и проверить его""" dest = "temp/configuration.yml" try: ctx.run( "uv run ansible localhost" " --module-name template" f' --args "src=files/authelia/configuration.template.yml dest={dest}"' " --extra-vars @group_vars/all/secrets.yml" " --extra-vars @files/authelia/secrets.yml" ) ctx.run(f"{AUTHELIA_DOCKER} validate-config --config /data/{dest}") finally: ctx.run(f"rm -f {dest}", warn=True) @task def authelia_gen_random_string(ctx: Context, length: int = 10) -> None: """Сгенерировать случайную alphanumeric-строку""" ctx.run(f"{AUTHELIA_DOCKER} crypto rand --length {length} --charset alphanumeric") @task def authelia_gen_secret_and_hash(ctx: Context, length: int = 72) -> None: """Сгенерировать случайный секрет и его pbkdf2-sha512 хэш""" ctx.run( f"{AUTHELIA_DOCKER} crypto hash generate pbkdf2" f" --variant sha512 --random --random.length {length} --random.charset rfc3986" ) @task def format_py_files(ctx: Context) -> None: """Отформатировать Python-файлы через Black в docker""" uid = os.getuid() gid = os.getgid() ctx.run( f"docker run --rm -u {uid}:{gid} -v $PWD:/app -w /app" " pyfound/black:latest_release black ." )