侧边栏壁纸
博主头像
天涯小筑

行动起来,活在当下

  • 累计撰写 8 篇文章
  • 累计创建 7 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

gitlab升级

gitlab升级需要阶梯式升级,比如18.4.x升级到19.0.x需要经过如下步骤:

18.5.5 → 18.8.x → 18.11.x → 19.0.x

升级脚本如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GitLab Docker Compose 阶梯升级脚本 v2

特点:
  1. 自动识别当前 GitLab 版本,优先读取 /opt/gitlab/version-manifest.txt。
  2. 自动查询 Docker Hub,解析每个大/小版本的最新 patch tag。
  3. 自动规划 required stop 升级路径。
  4. 升级前打印 step,等待用户输入 Y 后才执行。
  5. 每 5 秒输出容器、服务、版本、后台迁移状态。
  6. 升级完成后清理旧 gitlab 镜像 tag,不执行 docker system prune。

默认用法:
  python3 gitlab-upgrade-v2.py

常用参数:
  python3 gitlab-upgrade-v2.py --dry-run
  python3 gitlab-upgrade-v2.py --target 19.0
  python3 gitlab-upgrade-v2.py --current-version 18.5.5
  python3 gitlab-upgrade-v2.py --skip-gitlab-backup
"""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional, List, Tuple


REQUIRED_STOPS = {
    17: [2, 5, 8, 11],
    18: [2, 5, 8, 11],
    19: [2, 5, 8, 11],
    20: [2, 5, 8, 11],
}

IMAGE_LINE_RE = re.compile(
    r"(?m)^(\s*image:\s*)(['\"]?)(gitlab/gitlab-(?:ce|ee)):[^'\"\s#]+(['\"]?)(.*)$"
)
VERSION_RE = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")

USE_COLOR = True

COLORS = {
    "reset": "\033[0m",
    "info": "\033[36m",
    "step": "\033[35m",
    "ok": "\033[32m",
    "warn": "\033[33m",
    "error": "\033[31m",
    "debug": "\033[90m",
}


@dataclass(frozen=True, order=True)
class Version:
    major: int
    minor: int
    patch: int = 0

    @classmethod
    def parse(cls, text: str) -> "Version":
        m = VERSION_RE.search(text or "")
        if not m:
            raise ValueError("无法解析版本号:%s" % text)
        return cls(int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))

    def major_minor(self) -> str:
        return "%d.%d" % (self.major, self.minor)

    def full(self) -> str:
        return "%d.%d.%d" % (self.major, self.minor, self.patch)


def now() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def color_text(level: str, text: str) -> str:
    if not USE_COLOR:
        return text
    return COLORS.get(level, "") + text + COLORS["reset"]


def log(msg: str, level: str = "info") -> None:
    print(color_text(level, "[%s] %s" % (now(), msg)), flush=True)


def die(msg: str, code: int = 1) -> None:
    print(color_text("error", "[%s] ERROR: %s" % (now(), msg)), file=sys.stderr, flush=True)
    sys.exit(code)


def run(cmd: List[str], *, check: bool = True, capture: bool = False,
        timeout: Optional[int] = None, cwd: Optional[Path] = None) -> subprocess.CompletedProcess:
    try:
        if capture:
            p = subprocess.run(
                cmd,
                text=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=timeout,
                cwd=str(cwd) if cwd else None,
            )
        else:
            p = subprocess.run(
                cmd,
                text=True,
                timeout=timeout,
                cwd=str(cwd) if cwd else None,
            )
    except subprocess.TimeoutExpired:
        if check:
            die("命令执行超时:%s" % " ".join(cmd))
        raise

    if check and p.returncode != 0:
        if capture:
            if p.stdout:
                print(p.stdout, end="")
            if p.stderr:
                print(p.stderr, end="", file=sys.stderr)
        die("命令执行失败:%s" % " ".join(cmd))
    return p


def run_output(cmd: List[str], *, check: bool = True, timeout: Optional[int] = None) -> str:
    p = run(cmd, check=check, capture=True, timeout=timeout)
    return (p.stdout or "").strip()


def detect_compose_cmd() -> List[str]:
    if run(["docker", "compose", "version"], check=False, capture=True).returncode == 0:
        return ["docker", "compose"]
    if run(["docker-compose", "version"], check=False, capture=True).returncode == 0:
        return ["docker-compose"]
    die("未找到 docker compose 或 docker-compose")


def compose(compose_cmd: List[str], compose_file: Path, extra: List[str]) -> List[str]:
    return compose_cmd + ["-f", str(compose_file)] + extra


def detect_image_repo(compose_file: Path, fallback_repo: str) -> str:
    text = compose_file.read_text(encoding="utf-8")
    m = IMAGE_LINE_RE.search(text)
    if m:
        return m.group(3)
    log("未在 compose 文件中识别到 gitlab/gitlab-ce 或 gitlab/gitlab-ee 的 image 行,使用默认:%s" % fallback_repo, "warn")
    return fallback_repo


def replace_compose_image(compose_file: Path, image_repo: str, tag: str) -> None:
    text = compose_file.read_text(encoding="utf-8")

    def repl(m: re.Match) -> str:
        quote1 = m.group(2) or ""
        quote2 = m.group(4) or quote1
        return "%s%s%s:%s%s%s" % (m.group(1), quote1, image_repo, tag, quote2, m.group(5))

    new_text, count = IMAGE_LINE_RE.subn(repl, text, count=1)
    if count == 0:
        die("没有找到可替换的 GitLab image 行。请确认存在:image: gitlab/gitlab-ce:18.5.5-ce.0")
    compose_file.write_text(new_text, encoding="utf-8")


def docker_inspect_state(container: str) -> dict:
    p = run(["docker", "inspect", container], check=False, capture=True, timeout=20)
    if p.returncode != 0:
        return {"exists": False, "error": (p.stderr or p.stdout or "").strip()}
    try:
        data = json.loads(p.stdout)[0]
    except Exception as e:
        return {"exists": False, "error": str(e)}
    state = data.get("State", {})
    return {
        "exists": True,
        "status": state.get("Status"),
        "running": state.get("Running"),
        "restarting": state.get("Restarting"),
        "exit_code": state.get("ExitCode"),
        "health": (state.get("Health") or {}).get("Status", "none"),
    }


def docker_running(container: str) -> bool:
    s = docker_inspect_state(container)
    return bool(s.get("exists") and s.get("running"))


def get_gitlab_version(container: str) -> Optional[Version]:
    """获取 GitLab 当前版本。

    关键:优先执行用户现场验证可用的 version-manifest grep。
    兼容:
      gitlab-ce 18.5.5
      gitlab-ee 18.5.5
      gitlab-ce 18.5.5-ce.0
      18.5.5-ce.0
    """
    commands = [
        ["docker", "exec", container, "bash", "-lc", "grep -E '^gitlab-(ce|ee)[[:space:]]+' /opt/gitlab/version-manifest.txt | head -1"],
        ["docker", "exec", container, "bash", "-lc", "cat /opt/gitlab/version-manifest.txt | head -120"],
        ["docker", "exec", container, "bash", "-lc", "dpkg-query -W -f='${Version}' gitlab-ce 2>/dev/null || dpkg-query -W -f='${Version}' gitlab-ee 2>/dev/null"],
        ["docker", "exec", container, "gitlab-rake", "gitlab:env:info"],
    ]

    last_output = ""
    for idx, cmd in enumerate(commands):
        timeout_sec = 20 if idx < 3 else 60
        try:
            p = run(cmd, check=False, capture=True, timeout=timeout_sec)
        except subprocess.TimeoutExpired:
            log("版本检测命令超时,继续尝试下一个:%s" % " ".join(cmd), "warn")
            continue

        text = ((p.stdout or "") + "\n" + (p.stderr or "")).strip()
        if text:
            last_output = text[:500]

        patterns = [
            r"gitlab-(?:ce|ee)\s+(\d+\.\d+\.\d+)(?:-[^\s]+)?",
            r"GitLab version\s+(\d+\.\d+\.\d+)",
            r"(?:^|\s)(\d+\.\d+\.\d+)(?:-[a-z]+\.\d+)?(?:\s|$)",
        ]
        for pattern in patterns:
            m = re.search(pattern, text, re.MULTILINE)
            if m:
                return Version.parse(m.group(1))

    if last_output:
        log("版本识别失败,最后输出片段:%s" % last_output.replace("\n", " | "), "warn")
    return None


def gitlab_ctl_status_detail(container: str) -> Tuple[bool, str, int]:
    p = run(["docker", "exec", container, "gitlab-ctl", "status"], check=False, capture=True, timeout=120)
    text = ((p.stdout or "") + "\n" + (p.stderr or "")).strip()
    running_count = len(re.findall(r"^run:", text, re.MULTILINE))
    return p.returncode == 0, text, running_count


def get_pending_background_migrations(container: str) -> Optional[int]:
    sql = "SELECT COUNT(*) FROM batched_background_migrations WHERE status NOT IN (3, 6);"
    p = run(["docker", "exec", container, "gitlab-psql", "-tAc", sql], check=False, capture=True, timeout=120)
    text = ((p.stdout or "") + "\n" + (p.stderr or "")).strip()
    if p.returncode != 0:
        lower_text = text.lower()

        not_ready_keywords = [
            "could not connect",
            "starting up",
            "could not load /opt/gitlab/etc/gitlab-psql-rc",
            "does not exist yet",
            "gitlab-psql-rc",
        ]

        if any(k in lower_text for k in not_ready_keywords):
            return None

        raise RuntimeError(text)
    m = re.search(r"\d+", text)
    return int(m.group(0)) if m else None


def wait_until_running(container: str, timeout_sec: int, interval: int) -> None:
    log("开始等待容器进入 running 状态,检查间隔:%ss" % interval, "step")
    deadline = time.time() + timeout_sec
    while time.time() < deadline:
        s = docker_inspect_state(container)
        if s.get("exists") and s.get("running"):
            log("容器状态检查通过:status=%s running=%s restarting=%s exit_code=%s health=%s" % (
                s.get("status"), s.get("running"), s.get("restarting"), s.get("exit_code"), s.get("health")
            ), "ok")
            return
        log("容器未就绪:%s" % s, "warn")
        time.sleep(interval)
    die("容器 %s 未在 %ss 内进入 running 状态" % (container, timeout_sec))


def wait_gitlab_services(container: str, timeout_sec: int, interval: int) -> None:
    log("开始等待 gitlab-ctl status 通过,检查间隔:%ss" % interval, "step")
    deadline = time.time() + timeout_sec
    while time.time() < deadline:
        ok, text, count = gitlab_ctl_status_detail(container)
        if ok:
            log("gitlab-ctl status 检查通过,running services=%s" % count, "ok")
            return
        first_lines = " | ".join(text.splitlines()[:3]) if text else "无输出"
        log("gitlab-ctl status 未就绪,running services=%s,输出:%s" % (count, first_lines), "warn")
        time.sleep(interval)
    die("gitlab-ctl status 在 %ss 内未通过" % timeout_sec)


def wait_gitlab_version(container: str, expected: Version, timeout_sec: int, interval: int) -> None:
    log("开始确认 GitLab 版本,期望版本:%s,检查间隔:%ss" % (expected.full(), interval), "step")
    deadline = time.time() + timeout_sec
    last_seen = "未知"
    while time.time() < deadline:
        v = get_gitlab_version(container)
        if v:
            last_seen = v.full()
            log("版本状态:current=%s expected=%s" % (last_seen, expected.full()), "info")
            if v == expected:
                log("GitLab 当前版本确认:%s" % v.full(), "ok")
                return
        else:
            log("暂未识别到 GitLab 版本,继续等待...", "warn")
        time.sleep(interval)

    tail = run(["docker", "logs", "--tail", "120", container], check=False, capture=True, timeout=60)
    if tail.stdout:
        print("\n========== docker logs tail ==========")
        print(tail.stdout)
    die("等待 GitLab 升级到 %s 超时,最后识别版本:%s" % (expected.full(), last_seen))


def wait_background_migrations(container: str, timeout_sec: int, interval: int) -> None:
    log("开始检查 batched background migrations,检查间隔:%ss" % interval, "step")
    deadline = time.time() + timeout_sec
    while time.time() < deadline:
        try:
            pending = get_pending_background_migrations(container)
        except RuntimeError as e:
            die("查询后台迁移状态失败:%s" % e)
        if pending == 0:
            log("后台迁移检查通过:未完成数量为 0", "ok")
            return
        if pending is None:
            log("数据库暂未就绪,继续等待...", "warn")
        else:
            log("后台迁移未完成数量:%s,继续等待..." % pending, "warn")
        time.sleep(interval)
    die("后台迁移在 %ss 内未完成,不继续升级" % timeout_sec)


def preflight_ready_to_upgrade(container: str, timeout_sec: int, interval: int) -> None:
    log("升级前检查:确认当前容器是否处于可继续升级状态", "step")
    wait_until_running(container, timeout_sec, interval)
    wait_gitlab_services(container, timeout_sec, interval)
    v = get_gitlab_version(container)
    if v:
        log("当前版本识别成功:%s" % v.full(), "ok")
    else:
        log("当前版本未识别成功,但如果你通过 --current-version 指定了版本,后续仍可继续", "warn")
    wait_background_migrations(container, timeout_sec, interval)


def backup_compose_file(compose_file: Path, backup_dir: Path) -> None:
    backup_dir.mkdir(parents=True, exist_ok=True)
    dst = backup_dir / (compose_file.name + ".bak." + datetime.now().strftime("%Y%m%d%H%M%S"))
    shutil.copy2(compose_file, dst)
    log("已备份 compose 文件:%s" % dst, "ok")


def copy_gitlab_config(container: str, backup_dir: Path) -> None:
    backup_dir.mkdir(parents=True, exist_ok=True)
    tmp_dir = backup_dir / ("etc_gitlab_" + datetime.now().strftime("%Y%m%d%H%M%S"))
    archive = backup_dir / (tmp_dir.name + ".tar.gz")
    log("备份 /etc/gitlab 配置...", "step")
    run(["docker", "cp", "%s:/etc/gitlab" % container, str(tmp_dir)], check=True)
    with tarfile.open(archive, "w:gz") as tar:
        tar.add(tmp_dir, arcname=tmp_dir.name)
    shutil.rmtree(tmp_dir, ignore_errors=True)
    log("已备份 /etc/gitlab 配置:%s" % archive, "ok")


def create_gitlab_backup(container: str, full_backup: bool) -> None:
    if full_backup:
        cmd = ["docker", "exec", "-t", container, "gitlab-backup", "create"]
        log("开始创建 GitLab 完整备份,数据量大时会比较久...", "step")
    else:
        cmd = [
            "docker", "exec", "-t", container, "gitlab-backup", "create",
            "SKIP=artifacts,repositories,registry,uploads,builds,pages,lfs,packages,terraform_state",
        ]
        log("开始创建 GitLab 数据库备份,跳过仓库/附件等大文件...", "step")
    run(cmd, check=True)
    log("GitLab 备份命令执行完成", "ok")


def ensure_free_space(path: Path, min_free_gb: int) -> None:
    usage = shutil.disk_usage(path)
    free_gb = usage.free / 1024 / 1024 / 1024
    if free_gb < min_free_gb:
        die("%s 剩余空间约 %.1fGB,小于要求的 %sGB" % (path, free_gb, min_free_gb))
    log("磁盘空间检查通过:%s 剩余约 %.1fGB" % (path, free_gb), "ok")


def normalize_plan_item(item: str) -> str:
    item = (item or "").strip()
    if item.startswith("v"):
        item = item[1:]
    if not item:
        raise ValueError("升级计划中存在空版本")
    return item


def build_auto_plan(current: Version, target: Version) -> List[str]:
    if current >= target:
        return []
    plan = []
    for major in range(current.major, target.major + 1):
        stops = REQUIRED_STOPS.get(major, [2, 5, 8, 11])
        start_minor = current.minor if major == current.major else -1
        end_minor = target.minor if major == target.major else 999
        for minor in stops:
            if start_minor < minor <= end_minor:
                plan.append("%d.%d" % (major, minor))
    target_mm = target.major_minor()
    if target_mm not in plan:
        plan.append(target_mm)
    return plan


def parse_plan(plan_text: str, current: Version, target: Version) -> List[str]:
    if (plan_text or "auto").strip().lower() == "auto":
        raw = build_auto_plan(current, target)
    else:
        raw = [normalize_plan_item(x) for x in plan_text.split(",") if x.strip()]

    result = []
    for item in raw:
        v = Version.parse(item)
        if v <= current:
            log("跳过已低于或等于当前版本的升级点:%s" % item, "warn")
            continue
        if v > target:
            log("跳过超过目标版本的升级点:%s" % item, "warn")
            continue
        result.append(item)
    return result


def dockerhub_latest_tag(image_repo: str, major_minor: str, edition: str, timeout_sec: int = 30) -> Optional[str]:
    try:
        namespace, name = image_repo.split("/", 1)
    except ValueError:
        return None

    url = "https://hub.docker.com/v2/repositories/%s/%s/tags/?page_size=100&name=%s." % (
        namespace, name, major_minor
    )
    tags = []
    pattern = re.compile(r"^%s\.(\d+)-%s\.0$" % (re.escape(major_minor), re.escape(edition)))

    while url:
        req = urllib.request.Request(url, headers={"User-Agent": "gitlab-step-upgrade-v2/1.0"})
        try:
            with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
                data = json.loads(resp.read().decode("utf-8"))
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
            log("查询 Docker Hub 失败:%s" % e, "warn")
            return None

        for item in data.get("results", []):
            tag_name = item.get("name", "")
            m = pattern.match(tag_name)
            if m:
                tags.append((int(m.group(1)), tag_name))
        url = data.get("next")

    if not tags:
        return None
    tags.sort()
    return tags[-1][1]


def resolve_tag(image_repo: str, version_text: str, edition: str) -> Tuple[str, Version]:
    item = normalize_plan_item(version_text)
    if re.search(r"-%s\.0$" % re.escape(edition), item):
        return item, Version.parse(item)
    if re.fullmatch(r"\d+\.\d+\.\d+", item):
        tag = "%s-%s.0" % (item, edition)
        return tag, Version.parse(item)
    if re.fullmatch(r"\d+\.\d+", item):
        log("查询 Docker Hub:%s:%s.x 最新版本..." % (image_repo, item), "step")
        tag = dockerhub_latest_tag(image_repo, item, edition)
        if not tag:
            die("无法自动查询 %s:%s.x 的最新 Docker tag。可手工指定 --plan %s.0,18.11.0,19.0.0" % (image_repo, item, item))
        log("Docker Hub 最新匹配版本:%s:%s" % (image_repo, tag), "ok")
        return tag, Version.parse(tag)
    raise ValueError("不支持的版本格式:%s" % version_text)


def print_plan(current: Version, target: Version, image_repo: str, resolved_plan: List[Tuple[str, Version]]) -> None:
    print("\n" + color_text("step", "=" * 78))
    print(color_text("step", "GitLab Docker 阶梯升级计划"))
    print(color_text("step", "=" * 78))
    print("当前版本 : %s" % current.full())
    print("目标版本 : %s" % target.full())
    print("镜像仓库 : %s" % image_repo)
    print("升级步骤 :")
    prev = current.full()
    for i, (tag, v) in enumerate(resolved_plan, 1):
        print("  %d. %s  ->  %s:%s" % (i, prev, image_repo, tag))
        prev = v.full()
    print(color_text("step", "=" * 78) + "\n")


def confirm_or_exit(yes: bool) -> None:
    if yes:
        log("已通过 --yes 跳过人工确认", "warn")
        return
    ans = input(color_text("warn", "确认执行以上升级步骤?输入大写 Y 继续,其它任意输入退出:")).strip()
    if ans != "Y":
        die("用户取消升级", code=0)


def pull_image(compose_cmd: List[str], compose_file: Path, service: str) -> None:
    log("开始拉取镜像...", "step")
    run(compose(compose_cmd, compose_file, ["pull", service]), check=True)


def up_service(compose_cmd: List[str], compose_file: Path, service: str) -> None:
    log("启动 GitLab 服务...", "step")
    run(compose(compose_cmd, compose_file, ["up", "-d", service]), check=True)


def cleanup_old_gitlab_images(image_repo: str, keep_tag: str, dry_run: bool) -> None:
    log("开始清理旧 GitLab 镜像,只保留:%s:%s" % (image_repo, keep_tag), "step")
    output = run_output(["docker", "images", image_repo, "--format", "{{json .}}"], check=False, timeout=60)
    if not output.strip():
        log("没有找到可清理的 GitLab 镜像", "ok")
        return

    removed = 0
    for line in output.splitlines():
        try:
            item = json.loads(line)
        except json.JSONDecodeError:
            continue
        repo = item.get("Repository")
        tag = item.get("Tag")
        if repo != image_repo or not tag or tag == keep_tag:
            continue
        image_ref = "%s:%s" % (repo, tag)
        if dry_run:
            log("[dry-run] 将清理:%s" % image_ref, "warn")
            removed += 1
            continue
        p = run(["docker", "image", "rm", image_ref], check=False, capture=True, timeout=120)
        if p.returncode == 0:
            log("已清理:%s" % image_ref, "ok")
            removed += 1
        else:
            log("跳过无法删除的镜像:%s,可能仍被容器引用" % image_ref, "warn")
    if removed == 0:
        log("没有需要清理的旧 GitLab 镜像", "ok")


def main() -> None:
    global USE_COLOR

    parser = argparse.ArgumentParser(description="GitLab Docker Compose 阶梯升级脚本 v2")
    parser.add_argument("--compose-file", default="docker-compose.yml", help="docker-compose.yml 路径")
    parser.add_argument("--service", default="gitlab", help="compose service 名称,默认 gitlab")
    parser.add_argument("--container", default="gitlab", help="GitLab 容器名称,默认 gitlab")
    parser.add_argument("--image-repo", default="", help="gitlab/gitlab-ce 或 gitlab/gitlab-ee;默认从 compose 检测")
    parser.add_argument("--edition", choices=["ce", "ee"], default="", help="ce 或 ee;默认从 image-repo 检测")
    parser.add_argument("--current-version", default="", help="当前版本;容器无法读取时手工指定,例如 18.5.5")
    parser.add_argument("--target", default="19.0.0", help="目标版本,默认 19.0.0;也可填 19.0")
    parser.add_argument("--plan", default="auto", help="升级计划,默认 auto;也可指定 18.8,18.11,19.0")
    parser.add_argument("--backup-dir", default="./gitlab-upgrade-backups", help="本地备份目录")
    parser.add_argument("--skip-gitlab-backup", action="store_true", help="跳过 gitlab-backup create")
    parser.add_argument("--full-backup", action="store_true", help="完整备份,不跳过仓库/附件")
    parser.add_argument("--skip-config-backup", action="store_true", help="跳过 /etc/gitlab 配置备份")
    parser.add_argument("--skip-cleanup", action="store_true", help="升级完成后不清理旧 GitLab 镜像")
    parser.add_argument("--dry-run", action="store_true", help="只打印计划,不修改 compose、不升级")
    parser.add_argument("--yes", action="store_true", help="跳过确认,直接执行")
    parser.add_argument("--no-color", action="store_true", help="关闭彩色输出")
    parser.add_argument("--wait-timeout", type=int, default=3600, help="每级启动等待超时,默认 3600 秒")
    parser.add_argument("--migration-timeout", type=int, default=7200, help="后台迁移等待超时,默认 7200 秒")
    parser.add_argument("--interval", type=int, default=5, help="状态轮询间隔,默认 5 秒")
    parser.add_argument("--min-free-gb", type=int, default=10, help="最低剩余空间,默认 10GB")
    args = parser.parse_args()

    if args.no_color or not sys.stdout.isatty():
        USE_COLOR = False

    compose_file = Path(args.compose_file).resolve()
    if not compose_file.exists():
        die("文件不存在:%s" % compose_file)

    backup_root = Path(args.backup_dir).resolve()
    ensure_free_space(backup_root.parent if backup_root.parent.exists() else Path("."), args.min_free_gb)

    compose_cmd = detect_compose_cmd()
    image_repo = args.image_repo or detect_image_repo(compose_file, "gitlab/gitlab-ce")
    edition = args.edition or ("ee" if image_repo.endswith("-ee") else "ce")

    if args.current_version:
        current = Version.parse(args.current_version)
        log("使用手工指定当前版本:%s" % current.full(), "warn")
    else:
        if not docker_running(args.container):
            die("容器 %s 当前未运行,无法自动识别版本。可添加 --current-version 18.5.5" % args.container)
        current = get_gitlab_version(args.container)
        if not current:
            die("无法自动识别 GitLab 当前版本。可先执行:docker exec %s bash -lc \"grep -E '^gitlab-(ce|ee)[[:space:]]+' /opt/gitlab/version-manifest.txt | head -1\",或添加 --current-version 18.5.5" % args.container)
        log("识别到当前 GitLab 版本:%s" % current.full(), "ok")

    target = Version.parse(args.target)
    plan_items = parse_plan(args.plan, current, target)
    if not plan_items:
        log("没有需要执行的升级步骤", "ok")
        return

    resolved_plan = [resolve_tag(image_repo, item, edition) for item in plan_items]
    print_plan(current, target, image_repo, resolved_plan)

    if args.dry_run:
        log("dry-run 模式结束,未执行任何修改", "ok")
        return

    confirm_or_exit(args.yes)

    backup_run_dir = backup_root / datetime.now().strftime("%Y%m%d-%H%M%S")
    backup_compose_file(compose_file, backup_run_dir)

    preflight_ready_to_upgrade(args.container, min(args.wait_timeout, 600), args.interval)

    if docker_running(args.container):
        if not args.skip_config_backup:
            copy_gitlab_config(args.container, backup_run_dir)
        if not args.skip_gitlab_backup:
            create_gitlab_backup(args.container, args.full_backup)
    elif not args.skip_gitlab_backup:
        die("容器当前不可运行,无法执行 gitlab-backup create。确认已有数据卷备份后可加 --skip-gitlab-backup")

    final_tag = ""
    for tag, expected_version in resolved_plan:
        log("=" * 78, "step")
        log("开始升级到:%s:%s" % (image_repo, tag), "step")

        replace_compose_image(compose_file, image_repo, tag)
        pull_image(compose_cmd, compose_file, args.service)
        up_service(compose_cmd, compose_file, args.service)

        wait_until_running(args.container, args.wait_timeout, args.interval)
        wait_gitlab_services(args.container, args.wait_timeout, args.interval)
        wait_gitlab_version(args.container, expected_version, args.wait_timeout, args.interval)
        wait_background_migrations(args.container, args.migration_timeout, args.interval)

        final_tag = tag
        log("阶段升级完成:%s:%s" % (image_repo, tag), "ok")

    if final_tag and not args.skip_cleanup:
        cleanup_old_gitlab_images(image_repo, final_tag, dry_run=False)

    log("=" * 78, "step")
    log("GitLab 阶梯升级完成", "ok")
    log("当前 compose 文件已固定到:%s:%s" % (image_repo, final_tag), "ok")
    log("本次备份目录:%s" % backup_run_dir, "ok")


if __name__ == "__main__":
    main()

执行升级:

python3 gitlab-upgrade.py

0

评论区