- vars/vars.yml → group_vars/all/main.yml, vars/secrets.yml → group_vars/all/secrets.yml; boilerplate vars_files убран из всех плейбуков - имя secrets.yml сохранено намеренно: pre-commit-хук и .crushignore ищут слово secret(s) в имени файла - host_vars инвентаря теперь имеют приоритет над общими переменными
285 lines
9.8 KiB
Python
285 lines
9.8 KiB
Python
#!/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 = "timeweb.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> [name ...]"""
|
|
names = _rest_args()
|
|
if not names:
|
|
raise Exit("Укажи хотя бы один плейбук: inv pl -- <name> [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(f"uv run ansible-vault edit {path}", pty=True)
|
|
|
|
|
|
# --- Синхронизация общих ролей с каноном 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"]
|
|
|
|
|
|
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]:
|
|
"""Роли для синхронизации: из '-- <role> ...' или все из 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 [-- <role> ...]"""
|
|
_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 [-- <role> ...]"""
|
|
_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 ."
|
|
)
|