From ca88abbfc93498fb0f49a56547c2314b25e618a5 Mon Sep 17 00:00:00 2001 From: laily Date: Thu, 2 Jul 2026 12:33:40 +0800 Subject: [PATCH] feat: add modify docker config --- docker/daemon.d/base.json | 7 + docker/daemon.d/mirror.json | 5 + script/docker-daemon-config.py | 281 +++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 docker/daemon.d/base.json create mode 100644 docker/daemon.d/mirror.json create mode 100755 script/docker-daemon-config.py diff --git a/docker/daemon.d/base.json b/docker/daemon.d/base.json new file mode 100644 index 0000000..36a1872 --- /dev/null +++ b/docker/daemon.d/base.json @@ -0,0 +1,7 @@ +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + } +} diff --git a/docker/daemon.d/mirror.json b/docker/daemon.d/mirror.json new file mode 100644 index 0000000..83c2943 --- /dev/null +++ b/docker/daemon.d/mirror.json @@ -0,0 +1,5 @@ +{ + "registry-mirrors": [ + "https://docker.1ms.run" + ] +} diff --git a/script/docker-daemon-config.py b/script/docker-daemon-config.py new file mode 100755 index 0000000..0f796e0 --- /dev/null +++ b/script/docker-daemon-config.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +# 按类别增量更新 /etc/docker/daemon.json(仅补缺失项,不覆盖已有配置) + +from __future__ import annotations + +import argparse +import copy +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +DAEMON_JSON = Path("/etc/docker/daemon.json") +PATCH_DIR_NAME = Path("docker") / "daemon.d" + +CATEGORIES: dict[str, str] = { + "base": "日志轮转等基础项(log-driver / log-opts)", + "mirror": "镜像加速(registry-mirrors)", +} + + +def dotfile_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def usage_text() -> str: + cats = "\n".join(f" {name:<8} {desc}" for name, desc in CATEGORIES.items()) + return f"""用法: docker-daemon-config [选项] [类别 ...] + +将 ~/.dotfile/docker/daemon.d/ 下对应片段合并进 /etc/docker/daemon.json。 +已有 key 保留不变;仅插入缺失的 key(嵌套对象递归补全)。 + +类别: +{cats} + all 应用上述全部类别 + +示例: + sudo ff script docker-daemon-config base + sudo ff script docker-daemon-config mirror + sudo ff script docker-daemon-config all + sudo ff script docker-daemon-config base --dry-run + +选项: + -h, --help 显示此帮助 + --list 列出可用类别 + --dry-run 只打印合并结果,不写文件 + --no-backup 不备份现有 daemon.json + --no-restart 合并后不尝试 reload/restart docker +""" + + +def fill_missing(base: Any, patch: Any) -> Any: + """仅补 patch 中有、base 中没有的 key;已有 key 不覆盖。""" + if not isinstance(patch, dict): + return base + if not isinstance(base, dict): + return copy.deepcopy(patch) + + out = copy.deepcopy(base) + for key, patch_value in patch.items(): + if key not in out: + out[key] = copy.deepcopy(patch_value) + elif isinstance(out[key], dict) and isinstance(patch_value, dict): + out[key] = fill_missing(out[key], patch_value) + return out + + +def load_json(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8").strip() + if not text: + return {} + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError(f"{path} 根节点必须是 JSON 对象") + return data + + +def resolve_categories(names: list[str]) -> list[str]: + if not names: + return [] + if "all" in names: + return list(CATEGORIES.keys()) + unknown = [name for name in names if name not in CATEGORIES] + if unknown: + raise ValueError(f"未知类别: {', '.join(unknown)}") + # 去重并保持顺序 + seen: set[str] = set() + ordered: list[str] = [] + for name in names: + if name not in seen: + seen.add(name) + ordered.append(name) + return ordered + + +def patch_path(root: Path, category: str) -> Path: + return root / PATCH_DIR_NAME / f"{category}.json" + + +def collect_patches(root: Path, categories: list[str]) -> dict[str, dict[str, Any]]: + patches: dict[str, dict[str, Any]] = {} + for category in categories: + path = patch_path(root, category) + if not path.is_file(): + raise FileNotFoundError(f"缺少配置片段: {path}") + patches[category] = load_json(path) + return patches + + +def diff_added(before: dict[str, Any], after: dict[str, Any], prefix: str = "") -> list[str]: + added: list[str] = [] + for key, value in after.items(): + path = f"{prefix}{key}" + if key not in before: + added.append(path) + continue + if isinstance(value, dict) and isinstance(before.get(key), dict): + added.extend(diff_added(before[key], value, prefix=f"{path}.")) + return added + + +def validate_daemon_json(path: Path) -> None: + with path.open(encoding="utf-8") as fh: + json.load(fh) + + dockerd = shutil.which("dockerd") + if not dockerd: + return + try: + subprocess.run( + [dockerd, "--validate", "--config-file", str(path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() + raise RuntimeError(f"dockerd --validate 失败: {detail or exc}") from exc + + +def reload_docker() -> None: + if shutil.which("systemctl") is None: + print("[INFO] 未找到 systemctl,请手动重启 Docker 使配置生效") + return + + for action in ("reload", "restart"): + result = subprocess.run( + ["systemctl", action, "docker"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if result.returncode == 0: + print(f"[INFO] 已执行 systemctl {action} docker") + return + + print("[WARN] systemctl reload/restart docker 失败,请手动重启 Docker") + if result.stdout.strip(): + print(result.stdout.strip()) + + +def write_daemon_json(content: dict[str, Any], dry_run: bool, backup: bool) -> None: + serialized = json.dumps(content, indent=2, ensure_ascii=False) + "\n" + + if dry_run: + print(serialized) + return + + if os.geteuid() != 0: + print("[ERROR] 写入 /etc/docker/daemon.json 需要 root,请使用 sudo 运行") + sys.exit(1) + + DAEMON_JSON.parent.mkdir(parents=True, exist_ok=True) + + tmp_path = DAEMON_JSON.with_suffix(".json.tmp") + tmp_path.write_text(serialized, encoding="utf-8") + try: + validate_daemon_json(tmp_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + if backup and DAEMON_JSON.is_file(): + stamp = datetime.now().strftime("%Y%m%d%H%M%S") + backup_path = DAEMON_JSON.with_name(f"{DAEMON_JSON.name}.bak.{stamp}") + shutil.copy2(DAEMON_JSON, backup_path) + print(f"[INFO] 已备份到 {backup_path}") + + tmp_path.replace(DAEMON_JSON) + os.chmod(DAEMON_JSON, 0o644) + print(f"[INFO] 已写入 {DAEMON_JSON}") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("categories", nargs="*", help="要应用的类别") + parser.add_argument("-h", "--help", action="store_true") + parser.add_argument("--list", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--no-backup", action="store_true") + parser.add_argument("--no-restart", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + if args.help: + print(usage_text()) + return 0 + + if args.list: + for name, desc in CATEGORIES.items(): + print(f"{name}\t{desc}") + print("all\t应用全部类别") + return 0 + + try: + categories = resolve_categories(args.categories) + except ValueError as exc: + print(f"[ERROR] {exc}") + print("可用类别:", ", ".join([*CATEGORIES.keys(), "all"])) + return 1 + + if not categories: + print(usage_text()) + return 1 + + root = dotfile_root() + patch_root = root / PATCH_DIR_NAME + if not patch_root.is_dir(): + print(f"[ERROR] 配置目录不存在: {patch_root}") + return 1 + + existing: dict[str, Any] = {} + if DAEMON_JSON.is_file(): + try: + existing = load_json(DAEMON_JSON) + except json.JSONDecodeError as exc: + print(f"[ERROR] {DAEMON_JSON} 不是合法 JSON: {exc}") + return 1 + + try: + patches = collect_patches(root, categories) + except (FileNotFoundError, ValueError) as exc: + print(f"[ERROR] {exc}") + return 1 + + merged = copy.deepcopy(existing) + for category in categories: + before = copy.deepcopy(merged) + merged = fill_missing(merged, patches[category]) + added = diff_added(before, merged) + if added: + print(f"[INFO] 类别 {category} 新增: {', '.join(added)}") + else: + print(f"[INFO] 类别 {category} 无需变更(相关项已存在)") + + if merged == existing: + print("[INFO] daemon.json 无变化") + return 0 + + try: + write_daemon_json(merged, dry_run=args.dry_run, backup=not args.no_backup) + except Exception as exc: + print(f"[ERROR] {exc}") + return 1 + + if not args.dry_run and not args.no_restart: + reload_docker() + + return 0 + + +if __name__ == "__main__": + sys.exit(main())