import os import re import subprocess REF_PATTERN = re.compile(r"^[a-zA-Z0-9._/-]+$") def is_deploy_enabled(): return os.getenv("ALLOW_GIT_DEPLOY", "false").lower() in ("1", "true", "yes") def get_repo_path(): return os.getenv("GIT_REPO_PATH", "/repo") def get_git_remote(): return os.getenv("GIT_REMOTE_URL", "").strip() def get_container_name(): return os.getenv("CONTAINER_NAME", os.getenv("HOSTNAME", "photohost-web")) def _repo_ready(): repo = get_repo_path() return os.path.isdir(repo) and os.path.isdir(os.path.join(repo, ".git")) def _docker_available(): try: result = subprocess.run( ["docker", "info"], capture_output=True, text=True, timeout=10, ) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False def _git_base_cmd(repo): return ["git", "-c", f"safe.directory={repo}", "-C", repo] def _run_subprocess(cmd, timeout=120, cwd=None): result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd, ) output = (result.stderr or result.stdout or "").strip() return result.returncode == 0, output def _run_git_as_root(args, timeout=120): repo = get_repo_path() container = get_container_name() cmd = ["docker", "exec", "-u", "0", container] + _git_base_cmd(repo) + args return _run_subprocess(cmd, timeout=timeout) def _fix_repo_permissions(): if not _docker_available() or not _repo_ready(): return False repo = get_repo_path() container = get_container_name() fix_cmd = [ "docker", "exec", "-u", "0", container, "sh", "-c", f"chown -R appuser:appuser {repo} && chmod -R u+rwX {repo}/.git", ] ok, _ = _run_subprocess(fix_cmd, timeout=60) return ok def run_git(args, timeout=120): if not _repo_ready(): return False, f"Git-репозиторий не найден: {get_repo_path()}" repo = get_repo_path() cmd = _git_base_cmd(repo) + args ok, output = _run_subprocess(cmd, timeout=timeout) if ok: return True, output permission_error = "permission denied" in output.lower() and "config" in output.lower() if permission_error and _docker_available(): _fix_repo_permissions() ok, output = _run_subprocess(cmd, timeout=timeout) if ok: return True, output ok, output = _run_git_as_root(args, timeout=timeout) if ok: return True, output return False, output or "Git error" def run_ls_remote(extra_args=None, timeout=60): remote = get_git_remote() if not remote: return False, "GIT_REMOTE_URL не задан", [] cmd = ["git", "ls-remote", remote] if extra_args: cmd.extend(extra_args) ok, output = _run_subprocess(cmd, timeout=timeout) if not ok: return False, output or "ls-remote error", [] return True, "", output.splitlines() def fetch_remote(): remote = get_git_remote() if not remote: return run_git(["fetch", "--all", "--tags", "--prune"], timeout=180) # Fetch by URL only — never run `git remote set-url` or other config writes. return run_git( [ "fetch", "--tags", "--prune", remote, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*", ], timeout=180, ) def list_tags(): ok, err, lines = run_ls_remote(["--tags"]) if not ok: return [], err tags = [] for line in lines: parts = line.split() if len(parts) < 2: continue ref = parts[1] if not ref.startswith("refs/tags/"): continue tag = ref.removeprefix("refs/tags/") if tag.endswith("^{}"): continue tags.append(tag) tags = sorted(set(tags), reverse=True) return tags, None def list_branches(): ok, err, lines = run_ls_remote(["--heads"]) if not ok: return [], err branches = [] for line in lines: parts = line.split() if len(parts) < 2: continue ref = parts[1] if ref.startswith("refs/heads/"): branches.append(ref.removeprefix("refs/heads/")) return sorted(set(branches)), None def get_current_version(): if not _repo_ready(): return None, "Репозиторий недоступен" ok, tag = run_git(["describe", "--tags", "--always"]) if ok and tag: return tag, None ok, branch = run_git(["rev-parse", "--abbrev-ref", "HEAD"]) if ok: return branch, None return "unknown", None def checkout_version(ref): if not ref or not REF_PATTERN.match(ref): return False, "Недопустимое имя версии" ok, msg = fetch_remote() if not ok: return False, msg ok, msg = run_git(["checkout", ref]) if not ok: return False, msg return True, f"Переключено на {ref}" def deploy_rebuild(): if not is_deploy_enabled(): return False, "Обновление через админку отключено (ALLOW_GIT_DEPLOY=false)" repo = get_repo_path() compose_file = os.path.join(repo, "docker-compose.yml") if not os.path.isfile(compose_file): return False, f"Не найден {compose_file}" commands = [ ["docker", "compose", "-f", compose_file, "up", "-d", "--build"], ["docker-compose", "-f", compose_file, "up", "-d", "--build"], ] last_error = "" for cmd in commands: try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=600, cwd=repo, ) if result.returncode == 0: return True, result.stdout or "Контейнеры пересобраны и запущены" last_error = result.stderr or result.stdout except FileNotFoundError: last_error = f"Команда не найдена: {cmd[0]}" except subprocess.TimeoutExpired: return False, "Превышено время ожидания пересборки (10 мин)" return False, last_error or "Не удалось выполнить docker compose" def get_deploy_status(): current, _ = get_current_version() tags, tags_err = list_tags() branches, branches_err = list_branches() return { "enabled": is_deploy_enabled(), "repo_path": get_repo_path(), "repo_ready": _repo_ready(), "remote_url": get_git_remote(), "current": current, "tags": tags[:30], "branches": branches[:30], "tags_error": tags_err, "branches_error": branches_err, }