from __future__ import annotations # compose 模板生成需要解析 docker inspect 的 JSON。 import json # 本地模板目录和远端运行目录都走现有配置。 from pathlib import Path # 远端命令和文件路径都需要安全转义。 import shlex # 一些字段要做正则判断。 import re from typing import Any from core.config import ROOT, RUNTIME_BASE_DIR, load_hosts, utc_now from .runtime_config import read_remote_text, write_remote_text from .ssh_remote import copy_remote_file, run_host_script_checked # 本地完整 compose 模板统一放到 run 目录,避免把运行期产物写进 Git。 COMPOSE_TEMPLATE_DIR = ROOT / "run" / "compose_templates" def managed_compose_hosts() -> list[dict[str, Any]]: # 只维护当前 targets.json 里真正承载业务服务的宿主机。 return [host for host in load_hosts() if host.get("services")] def managed_compose_host_names() -> list[str]: # 页面和运维脚本都更适合拿主机名列表。 return [host["host"] for host in managed_compose_hosts()] def managed_host_map() -> dict[str, dict[str, Any]]: # 方便按主机名快速定位实例 ID 和服务清单。 return {host["host"]: host for host in managed_compose_hosts()} def compose_template_path(host_name: str) -> Path: # 每台机器一份本地模板文件。 return COMPOSE_TEMPLATE_DIR / f"{host_name}.yml" def compose_remote_path(host_name: str) -> str: # 远端 compose 路径沿用现网目录结构。 return f"{RUNTIME_BASE_DIR.rstrip('/')}/{host_name}/docker-compose.yml" def container_name(host_name: str, service_name: str) -> str: # 当前线上容器命名规则固定。 return f"likei-{host_name}-{service_name}" def docker_inspect_payload(host: dict[str, Any], service_names: list[str]) -> list[dict[str, Any]]: # 这里只抓 compose 需要的关键字段,避免远端 inspect 输出塞进一大坨无关 JSON。 container_names = [container_name(host["host"], service_name) for service_name in service_names] if not container_names: raise RuntimeError(f"no compose services configured for host: {host['host']}") quoted_names = " ".join(shlex.quote(name) for name in container_names) python_code = """ import json import sys payload = json.load(sys.stdin) for item in payload: config = item.get("Config") or {} host_config = item.get("HostConfig") or {} print( json.dumps( { "Name": item.get("Name"), "Config": { "Image": config.get("Image"), "StopSignal": config.get("StopSignal"), "WorkingDir": config.get("WorkingDir"), "Entrypoint": config.get("Entrypoint"), "Cmd": config.get("Cmd"), "Healthcheck": config.get("Healthcheck"), }, "HostConfig": { "NetworkMode": host_config.get("NetworkMode"), "RestartPolicy": {"Name": (host_config.get("RestartPolicy") or {}).get("Name")}, "StopTimeout": host_config.get("StopTimeout"), }, "Mounts": item.get("Mounts") or [], }, ensure_ascii=False, ) ) """.strip() script = f"""set -eu docker inspect {quoted_names} | python3 -c {shlex.quote(python_code)} """ result = run_host_script_checked( host, script, command_name=f"inspect-compose-{host['host']}", timeout_seconds=90, ) payload: list[dict[str, Any]] = [] for raw_line in str(result.get("output") or "").splitlines(): line = raw_line.strip() if not line: continue try: payload.append(json.loads(line)) except json.JSONDecodeError as exc: raise RuntimeError(f"invalid inspect field payload on {host['host']}") from exc if not payload: raise RuntimeError(f"empty docker inspect payload on {host['host']}") return payload def ns_to_duration_text(raw_value: int | None) -> str | None: # docker inspect 的健康检查时间单位是纳秒,compose 更适合秒级文本。 if not raw_value: return None if raw_value % 1_000_000_000 == 0: return f"{raw_value // 1_000_000_000}s" if raw_value % 1_000_000 == 0: return f"{raw_value // 1_000_000}ms" return str(raw_value) def stop_grace_period_text(raw_value: int | None) -> str | None: # docker inspect 的 StopTimeout 单位本来就是秒。 if raw_value is None or raw_value <= 0: return None return f"{int(raw_value)}s" def image_is_pullable(image_ref: str) -> bool: # 纯十六进制镜像 ID 不能当成远端可拉取镜像。 text = str(image_ref or "").strip() if not text: return False return re.fullmatch(r"[0-9a-f]{12,64}", text) is None def normalize_command(raw_value: Any) -> list[str] | str | None: # docker inspect 的 Cmd / Entrypoint 可能是数组、空值或字符串。 if raw_value is None: return None if isinstance(raw_value, list): return [str(item) for item in raw_value] if isinstance(raw_value, str): return raw_value return [str(item) for item in raw_value] def healthcheck_block(config: dict[str, Any]) -> dict[str, Any] | None: # 把 inspect 里的健康检查结构翻回 compose 可接受格式。 healthcheck = config.get("Healthcheck") or {} test = healthcheck.get("Test") if not test: return None block: dict[str, Any] = {"test": [str(item) for item in test]} interval = ns_to_duration_text(int(healthcheck.get("Interval") or 0)) timeout = ns_to_duration_text(int(healthcheck.get("Timeout") or 0)) start_period = ns_to_duration_text(int(healthcheck.get("StartPeriod") or 0)) retries = int(healthcheck.get("Retries") or 0) if interval: block["interval"] = interval if timeout: block["timeout"] = timeout if start_period: block["start_period"] = start_period if retries > 0: block["retries"] = retries return block def render_compose_yaml(service_map: dict[str, dict[str, Any]]) -> str: # 这里按需导入 PyYAML,和现有项目依赖策略保持一致。 try: import yaml except Exception as exc: # noqa: BLE001 raise RuntimeError(f"yaml renderer unavailable: {exc}") from exc payload = {"services": service_map} return yaml.safe_dump(payload, allow_unicode=True, sort_keys=False).rstrip() + "\n" def parse_compose_yaml(content: str) -> dict[str, Any]: # 服务更新需要读取本地模板并直接改 image 字段。 try: import yaml except Exception as exc: # noqa: BLE001 raise RuntimeError(f"yaml parser unavailable: {exc}") from exc payload = yaml.safe_load(content) or {} if not isinstance(payload, dict): raise RuntimeError("invalid compose yaml payload") services = payload.get("services") or {} if not isinstance(services, dict): raise RuntimeError("invalid compose yaml services block") return {"services": {str(name): dict(value or {}) for name, value in services.items()}} def render_volume_specs(host_name: str, mounts: list[dict[str, Any]]) -> list[str]: # 把 docker inspect 的挂载信息翻回 compose volumes。 host_dir = f"{RUNTIME_BASE_DIR.rstrip('/')}/{host_name}/" specs: list[str] = [] for mount in mounts: mount_type = str(mount.get("Type") or "").strip() source = str(mount.get("Source") or "").strip() destination = str(mount.get("Destination") or "").strip() if mount_type != "bind" or not source or not destination: continue rendered_source = source if source.startswith(host_dir): rendered_source = f"./{source[len(host_dir):]}" volume_spec = f"{rendered_source}:{destination}" if not bool(mount.get("RW")): volume_spec += ":ro" specs.append(volume_spec) return specs def uses_local_runtime_artifact(spec: str) -> bool: # 这些挂载代表服务依赖宿主机上的 jar / 二进制,不再适合镜像化发布。 text = str(spec or "").strip() return ( text.endswith(":/application/service.jar:ro") or text.endswith(":/application/service.jar") or text.endswith(":/application/service:ro") or text.endswith(":/application/service") ) def strip_local_runtime_artifacts(volume_specs: list[str]) -> list[str]: # 发布到 Harbor 后,需要把本地运行时制品挂载从模板里移掉。 return [spec for spec in volume_specs if not uses_local_runtime_artifact(spec)] def build_service_definition( host_name: str, service_name: str, inspect_payload: dict[str, Any], *, include_env_file: bool, ) -> dict[str, Any]: # 只抽 compose 真正需要的字段,避免把 docker inspect 的噪声全部带回来。 config = inspect_payload.get("Config") or {} host_config = inspect_payload.get("HostConfig") or {} service_definition: dict[str, Any] = {} image_ref = str(config.get("Image") or "").strip() if not image_ref: raise RuntimeError(f"missing image ref for {host_name}/{service_name}") service_definition["image"] = image_ref service_definition["container_name"] = str(inspect_payload.get("Name") or f"/{container_name(host_name, service_name)}").lstrip("/") network_mode = str(host_config.get("NetworkMode") or "").strip() if network_mode: service_definition["network_mode"] = network_mode restart_policy = str((host_config.get("RestartPolicy") or {}).get("Name") or "").strip() if restart_policy: service_definition["restart"] = restart_policy stop_signal = str(config.get("StopSignal") or "").strip() if stop_signal: service_definition["stop_signal"] = stop_signal stop_grace_period = stop_grace_period_text(host_config.get("StopTimeout")) if stop_grace_period: service_definition["stop_grace_period"] = stop_grace_period if include_env_file: service_definition["env_file"] = ["./service.env"] volume_specs = render_volume_specs(host_name, list(inspect_payload.get("Mounts") or [])) if volume_specs: service_definition["volumes"] = volume_specs # 如果运行时依赖本地挂载的 jar / 二进制,就不要再强制拉镜像。 uses_local_runtime_artifact = any( spec.endswith(":/application/service.jar:ro") or spec.endswith(":/application/service") or spec.endswith(":/application/service:ro") for spec in volume_specs ) if uses_local_runtime_artifact: service_definition["pull_policy"] = "never" elif image_is_pullable(image_ref): service_definition["pull_policy"] = "always" working_dir = str(config.get("WorkingDir") or "").strip() if working_dir: service_definition["working_dir"] = working_dir entrypoint = normalize_command(config.get("Entrypoint")) if entrypoint is not None: service_definition["entrypoint"] = entrypoint command = normalize_command(config.get("Cmd")) if command is not None: service_definition["command"] = command healthcheck = healthcheck_block(config) if healthcheck: service_definition["healthcheck"] = healthcheck return service_definition def generate_host_compose_template(host_name: str) -> dict[str, Any]: # 生成模板时严格按 targets.json 里的服务顺序输出,后续 diff 更稳定。 host_map = managed_host_map() host = host_map.get(host_name) if host is None: raise RuntimeError(f"unsupported compose host: {host_name}") service_names = [str(service.get("name") or "").strip() for service in host.get("services") or []] service_names = [service_name for service_name in service_names if service_name] if not service_names: raise RuntimeError(f"host has no managed services: {host_name}") inspect_items = docker_inspect_payload(host, service_names) inspect_by_name = { str((item.get("Name") or "").lstrip("/")): item for item in inspect_items } service_map: dict[str, dict[str, Any]] = {} missing_services: list[str] = [] for service_name in service_names: expected_container = container_name(host_name, service_name) inspect_item = inspect_by_name.get(expected_container) if inspect_item is None: missing_services.append(service_name) continue service_map[service_name] = build_service_definition( host_name, service_name, inspect_item, include_env_file=True, ) if missing_services: raise RuntimeError(f"missing running containers on {host_name}: {', '.join(missing_services)}") content = render_compose_yaml(service_map) return { "ok": True, "host": host_name, "services": list(service_map.keys()), "updatedAt": utc_now(), "content": content, "path": str(compose_template_path(host_name)), } def save_local_host_compose_template(host_name: str) -> dict[str, Any]: # 从远端运行态重新生成本地完整模板。 payload = generate_host_compose_template(host_name) path = compose_template_path(host_name) path.parent.mkdir(parents=True, exist_ok=True) previous = path.read_text(encoding="utf-8") if path.exists() else "" path.write_text(payload["content"], encoding="utf-8") return { **payload, "changed": previous != payload["content"], "source": "remote-runtime", } def load_local_host_compose_template(host_name: str) -> dict[str, Any]: # 本地模板优先作为远端同步的稳定来源。 path = compose_template_path(host_name) if not path.exists(): raise RuntimeError(f"local compose template missing: {host_name}") return { "ok": True, "host": host_name, "services": [str(service.get("name") or "").strip() for service in managed_host_map()[host_name].get("services") or []], "updatedAt": utc_now(), "content": path.read_text(encoding="utf-8"), "path": str(path), "source": "local-cache", } def ensure_local_host_compose_template(host_name: str) -> dict[str, Any]: # 第一次进入服务更新时,模板可能还没在本地生成。 path = compose_template_path(host_name) if path.exists(): return load_local_host_compose_template(host_name) return save_local_host_compose_template(host_name) def load_local_host_compose_model(host_name: str) -> dict[str, Any]: # 同时返回解析后的 services,方便直接修改模板。 template_payload = ensure_local_host_compose_template(host_name) parsed = parse_compose_yaml(str(template_payload.get("content") or "")) return { **template_payload, "services": parsed["services"], } def save_local_host_compose_model(host_name: str, service_map: dict[str, dict[str, Any]]) -> dict[str, Any]: # 模板编辑完成后,统一走这里回写磁盘并返回 diff 信息。 path = compose_template_path(host_name) path.parent.mkdir(parents=True, exist_ok=True) content = render_compose_yaml(service_map) previous = path.read_text(encoding="utf-8") if path.exists() else "" path.write_text(content, encoding="utf-8") return { "ok": True, "host": host_name, "updatedAt": utc_now(), "path": str(path), "content": content, "changed": previous != content, "source": "local-cache", "services": list(service_map.keys()), } def update_local_service_image(host_name: str, service_name: str, image_ref: str) -> dict[str, Any]: # 服务更新链路会把新镜像 digest 写回本地完整模板。 if not str(image_ref or "").strip(): raise RuntimeError("image ref is required") model = load_local_host_compose_model(host_name) service_map = dict(model.get("services") or {}) service_definition = dict(service_map.get(service_name) or {}) if not service_definition: raise RuntimeError(f"missing service in local compose template: {host_name}/{service_name}") service_definition["image"] = str(image_ref).strip() volume_specs = [str(item) for item in service_definition.get("volumes") or []] volume_specs = strip_local_runtime_artifacts(volume_specs) if volume_specs: service_definition["volumes"] = volume_specs else: service_definition.pop("volumes", None) if image_is_pullable(image_ref): service_definition["pull_policy"] = "always" else: service_definition.pop("pull_policy", None) service_map[service_name] = service_definition saved = save_local_host_compose_model(host_name, service_map) saved["service"] = service_name saved["image"] = str(image_ref).strip() return saved def verify_remote_compose(host: dict[str, Any], remote_path: str) -> list[str]: # 下发后立即让 docker compose 解析一遍,避免把坏文件写到线上却没人发现。 script = f"""set -eu FILE={shlex.quote(remote_path)} docker compose -f "$FILE" config --services """ result = run_host_script_checked( host, script, command_name=f"verify-compose-{host['host']}", timeout_seconds=60, ) return [line.strip() for line in str(result.get("output") or "").splitlines() if line.strip()] def sync_remote_host_compose_template(host_name: str, *, refresh_from_remote: bool = True) -> dict[str, Any]: # 对外暴露的主入口:优先刷新本地模板,再把完整 compose 同步到远端。 host_map = managed_host_map() host = host_map.get(host_name) if host is None: raise RuntimeError(f"unsupported compose host: {host_name}") template_payload: dict[str, Any] | None = None refresh_error = "" if refresh_from_remote: try: template_payload = save_local_host_compose_template(host_name) except Exception as exc: # noqa: BLE001 refresh_error = str(exc) if template_payload is None: template_payload = load_local_host_compose_template(host_name) template_payload["refreshError"] = refresh_error remote_path = compose_remote_path(host_name) try: remote_current = read_remote_text(host, remote_path) except Exception: remote_current = "" backup_path = "" if remote_current and remote_current != template_payload["content"]: backup_path = f"{remote_path}.hyapp.{utc_now().replace(':', '').replace('+', '_')}.bak" copy_remote_file(host, remote_path, backup_path) changed = remote_current != template_payload["content"] if changed: write_remote_text(host, remote_path, template_payload["content"]) remote_after = read_remote_text(host, remote_path) if remote_after != template_payload["content"]: raise RuntimeError(f"compose verify content mismatch on {host_name}") remote_services = verify_remote_compose(host, remote_path) return { "ok": True, "host": host_name, "services": template_payload["services"], "updatedAt": utc_now(), "path": remote_path, "templatePath": template_payload["path"], "changed": changed, "backupPath": backup_path, "remoteServices": remote_services, "source": template_payload.get("source") or "local-cache", "refreshError": template_payload.get("refreshError") or "", } def sync_remote_compose_templates(host_names: list[str], *, refresh_from_remote: bool = True) -> dict[str, Any]: # 允许对一组主机做完整模板同步。 normalized_names = [str(name or "").strip() for name in host_names if str(name or "").strip()] normalized_names = list(dict.fromkeys(normalized_names)) if not normalized_names: normalized_names = managed_compose_host_names() items = [sync_remote_host_compose_template(host_name, refresh_from_remote=refresh_from_remote) for host_name in normalized_names] return { "ok": True, "updatedAt": utc_now(), "hosts": items, }