#!/usr/bin/env python3 from __future__ import annotations import argparse import base64 import json import os import shlex import sys import time from pathlib import Path from typing import Any def load_env_file(path: str) -> None: # hy-app-monitor 现有 .env 可直接复用;这里只注入缺失变量,不覆盖调用方显式 export 的值。 if not path: return env_path = Path(path).expanduser().resolve() if not env_path.exists(): raise RuntimeError(f"env file not found: {env_path}") for raw_line in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip().strip('"').strip("'") if key and key not in os.environ: os.environ[key] = value def load_inventory(path: str) -> dict[str, Any]: inventory_path = Path(path).expanduser().resolve() with inventory_path.open("r", encoding="utf-8") as handle: inventory = json.load(handle) validate_inventory(inventory) return inventory def validate_inventory(inventory: dict[str, Any]) -> None: # 发布脚本只认明确的 host/service 映射,避免误操作同机其他项目。 if not isinstance(inventory.get("hosts"), dict) or not inventory["hosts"]: raise RuntimeError("inventory.hosts is required") if not isinstance(inventory.get("services"), dict) or not inventory["services"]: raise RuntimeError("inventory.services is required") for host_name, host in inventory["hosts"].items(): if not str(host.get("instance_id") or "").strip(): raise RuntimeError(f"hosts.{host_name}.instance_id is required") if not str(host.get("private_ip") or "").strip(): raise RuntimeError(f"hosts.{host_name}.private_ip is required") for service_name, service in inventory["services"].items(): for key in ("unit", "container", "env_file", "target_port", "hosts"): if key not in service: raise RuntimeError(f"services.{service_name}.{key} is required") for host_name in service["hosts"]: if host_name not in inventory["hosts"]: raise RuntimeError(f"services.{service_name}.hosts contains unknown host: {host_name}") def csv_values(text: str) -> list[str]: return [item.strip() for item in str(text or "").split(",") if item.strip()] def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]: names = csv_values(raw_services) if not names: raise RuntimeError("--services is required") unknown = [name for name in names if name not in inventory["services"]] if unknown: raise RuntimeError(f"unsupported services: {', '.join(unknown)}") return list(dict.fromkeys(names)) def selected_host_names(service: dict[str, Any], raw_hosts: str) -> list[str]: hosts = list(dict.fromkeys(str(item) for item in service.get("hosts") or [])) requested = csv_values(raw_hosts) if not requested: return hosts missing = [host for host in requested if host not in hosts] if missing: raise RuntimeError(f"selected hosts are not assigned to this service: {', '.join(missing)}") return requested def image_overrides(items: list[str]) -> dict[str, str]: overrides: dict[str, str] = {} for item in items: if "=" not in item: raise RuntimeError("--image must use service=image") service_name, image = item.split("=", 1) service_name = service_name.strip() image = image.strip() if not service_name or not image: raise RuntimeError("--image must use non-empty service=image") overrides[service_name] = image return overrides def render_image(inventory: dict[str, Any], service_name: str, tag: str, overrides: dict[str, str]) -> str: if service_name in overrides: return overrides[service_name] if not tag: raise RuntimeError(f"--tag or --image {service_name}=... is required for deploy") service = inventory["services"][service_name] registry = str(inventory.get("registry") or "").rstrip("/") template = str(service.get("image_template") or "${REGISTRY}/" + service_name + ":${TAG}") return template.replace("${REGISTRY}", registry).replace("${TAG}", tag) def build_plan( inventory: dict[str, Any], *, action: str, service_names: list[str], raw_hosts: str, tag: str, overrides: dict[str, str], no_clb: bool, ) -> list[dict[str, Any]]: # 顺序是服务优先、实例串行;启用 CLB 时不会同时摘掉同一组实例。 plan: list[dict[str, Any]] = [] for service_name in service_names: service = inventory["services"][service_name] image = render_image(inventory, service_name, tag, overrides) if action == "deploy" else "" for host_name in selected_host_names(service, raw_hosts): host = inventory["hosts"][host_name] clb = service.get("clb") or {} plan.append( { "action": action, "service": service_name, "host": host_name, "instance_id": host["instance_id"], "private_ip": host["private_ip"], "target_port": int(service["target_port"]), "unit": service["unit"], "container": service["container"], "image": image, "clb_enabled": bool(clb.get("enabled")) and not no_clb, "clb_load_balancer_id": str(clb.get("load_balancer_id") or ""), "clb_listener_ids": list(clb.get("listener_ids") or []), "drain_seconds": int(clb.get("drain_seconds") or 0), } ) return plan def secret_id() -> str: return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or "" def secret_key() -> str: return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or "" def region(inventory: dict[str, Any]) -> str: return os.environ.get("TENCENTCLOUD_REGION") or os.environ.get("DEPLOY_REGION") or str(inventory.get("region") or "") def require_tencent_credentials(inventory: dict[str, Any]) -> tuple[str, str, str]: sid = secret_id() skey = secret_key() reg = region(inventory) missing = [] if not sid: missing.append("TENCENTCLOUD_SECRET_ID") if not skey: missing.append("TENCENTCLOUD_SECRET_KEY") if not reg: missing.append("TENCENTCLOUD_REGION") if missing: raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing)) return sid, skey, reg def build_tat_client(inventory: dict[str, Any]) -> Any: sid, skey, reg = require_tencent_credentials(inventory) try: from tencentcloud.common import credential from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.http_profile import HttpProfile from tencentcloud.tat.v20201028 import tat_client except Exception as exc: # noqa: BLE001 raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc return tat_client.TatClient( credential.Credential(sid, skey), reg, ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), ) def build_clb_client(inventory: dict[str, Any]) -> Any: sid, skey, reg = require_tencent_credentials(inventory) try: from tencentcloud.clb.v20180317 import clb_client from tencentcloud.common import credential from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.http_profile import HttpProfile except Exception as exc: # noqa: BLE001 raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc return clb_client.ClbClient( credential.Credential(sid, skey), reg, ClientProfile(httpProfile=HttpProfile(endpoint="clb.tencentcloudapi.com")), ) def invoke_tencent(action: Any, label: str, retries: int = 3) -> Any: try: from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException except Exception: # noqa: BLE001 TencentCloudSDKException = Exception # type: ignore[assignment] last_error: Exception | None = None for attempt in range(1, retries + 1): try: return action() except TencentCloudSDKException as exc: # type: ignore[misc] last_error = exc if attempt == retries: break time.sleep(2 * attempt) raise RuntimeError(f"{label} failed: {last_error}") from last_error def decode_tat_output(output: str) -> str: if not output: return "" return base64.b64decode(output).decode("utf-8", errors="replace") def run_tat_shell( client: Any, *, instance_id: str, script: str, command_name: str, timeout_seconds: int, poll_seconds: int, ) -> dict[str, Any]: # TAT 是本方案唯一远端入口;脚本不会要求目标机开放 SSH。 from tencentcloud.tat.v20201028 import models as tat_models request = tat_models.RunCommandRequest() request.from_json_string( json.dumps( { "CommandName": command_name, "CommandType": "SHELL", "Content": base64.b64encode(script.encode("utf-8")).decode("ascii"), "InstanceIds": [instance_id], "Timeout": timeout_seconds, "WorkingDirectory": "/root", } ) ) response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string()) invocation_id = response["InvocationId"] deadline = time.time() + timeout_seconds + 60 while time.time() < deadline: time.sleep(max(poll_seconds, 1)) query = tat_models.DescribeInvocationTasksRequest() query.from_json_string( json.dumps( { "HideOutput": False, "Limit": 50, "Filters": [{"Name": "invocation-id", "Values": [invocation_id]}], } ) ) payload = json.loads(invoke_tencent(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string()) for task in payload.get("InvocationTaskSet") or []: if str(task.get("InstanceId") or "") != instance_id: continue status = str(task.get("TaskStatus") or "") if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}: return { "status": status, "output": decode_tat_output(str((task.get("TaskResult") or {}).get("Output") or "")), } return {"status": "TIMEOUT", "output": ""} def call_clb(client: Any, method_name: str, payload: dict[str, Any]) -> dict[str, Any]: from tencentcloud.clb.v20180317 import models as clb_models request_class = getattr(clb_models, f"{method_name}Request") request = request_class() request.from_json_string(json.dumps(payload)) response = invoke_tencent(lambda: getattr(client, method_name)(request), method_name) decoded = json.loads(response.to_json_string()) if isinstance(decoded.get("Response"), dict): return dict(decoded["Response"]) return dict(decoded) def wait_clb_task(client: Any, task_id: str, *, timeout_seconds: int, poll_seconds: int) -> None: # ModifyTargetWeight 是异步任务,必须轮询到成功后才能重启实例。 deadline = time.time() + max(timeout_seconds, 1) while time.time() < deadline: payload = call_clb(client, "DescribeTaskStatus", {"TaskId": task_id}) status = int(payload.get("Status") if payload.get("Status") is not None else 2) if status == 0: return if status == 1: raise RuntimeError(f"CLB task failed: {payload.get('Message') or task_id}") time.sleep(max(poll_seconds, 1)) raise RuntimeError(f"CLB task timed out: {task_id}") def target_matches(step: dict[str, Any], target: dict[str, Any]) -> bool: instance_id = str(target.get("InstanceId") or target.get("TargetId") or "").strip() if instance_id and instance_id != step["instance_id"]: return False target_port = int(target.get("Port") or 0) if target_port and target_port != int(step["target_port"]): return False ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or [target.get("IP")]) if str(item or "").strip()] if ips and step["private_ip"] not in ips: return False return bool(instance_id or ips) def binding_key(binding: dict[str, Any]) -> str: return "|".join( [ str(binding.get("listener_id") or ""), str(binding.get("location_id") or ""), str(binding.get("instance_id") or ""), str(int(binding.get("port") or 0)), ] ) def collect_bindings(step: dict[str, Any], listeners: list[dict[str, Any]]) -> list[dict[str, Any]]: bindings: dict[str, dict[str, Any]] = {} for listener in listeners: listener_id = str(listener.get("ListenerId") or "").strip() for target in listener.get("Targets") or []: if target_matches(step, target): binding = make_binding(step, listener_id, "", target) bindings[binding_key(binding)] = binding for rule in listener.get("Rules") or []: location_id = str(rule.get("LocationId") or "").strip() for target in rule.get("Targets") or []: if target_matches(step, target): binding = make_binding(step, listener_id, location_id, target) bindings[binding_key(binding)] = binding return list(bindings.values()) def make_binding(step: dict[str, Any], listener_id: str, location_id: str, target: dict[str, Any]) -> dict[str, Any]: return { "listener_id": listener_id, "location_id": location_id, "instance_id": str(target.get("InstanceId") or target.get("TargetId") or step["instance_id"]).strip(), "port": int(target.get("Port") or step["target_port"]), "weight": int(target.get("Weight") or 0), } def prepare_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]: if should_auto_discover_clb(step): return discover_clb_snapshot(client, step) payload = call_clb( client, "DescribeTargets", { "LoadBalancerId": step["clb_load_balancer_id"], "ListenerIds": step["clb_listener_ids"], }, ) bindings = collect_bindings(step, list(payload.get("Listeners") or [])) if not bindings: return discover_clb_snapshot(client, step) return {"step": step, "bindings": bindings} def should_auto_discover_clb(step: dict[str, Any]) -> bool: load_balancer_id = str(step.get("clb_load_balancer_id") or "").strip() listener_ids = [str(item or "").strip() for item in step.get("clb_listener_ids") or []] if not load_balancer_id or "REPLACE" in load_balancer_id or load_balancer_id.upper() == "AUTO": return True return any((not item) or "REPLACE" in item or item.upper() == "AUTO" for item in listener_ids) def discover_load_balancers_for_backend(client: Any, step: dict[str, Any]) -> list[dict[str, Any]]: # 腾讯云 CLB 支持按后端私网 IP 反查负载均衡,避免手填内网 CLB ID。 offset = 0 found: list[dict[str, Any]] = [] while True: payload = call_clb( client, "DescribeLoadBalancers", { "BackendPrivateIps": [step["private_ip"]], "WithRs": 1, "Limit": 100, "Offset": offset, }, ) items = list(payload.get("LoadBalancerSet") or []) found.extend(items) total = int(payload.get("TotalCount") or len(found)) if len(found) >= total or not items: break offset += len(items) return found def discover_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]: load_balancers = discover_load_balancers_for_backend(client, step) matched: list[dict[str, Any]] = [] for load_balancer in load_balancers: load_balancer_id = str(load_balancer.get("LoadBalancerId") or "").strip() if not load_balancer_id: continue payload = call_clb(client, "DescribeTargets", {"LoadBalancerId": load_balancer_id}) bindings = collect_bindings(step, list(payload.get("Listeners") or [])) if bindings: matched.append({"load_balancer_id": load_balancer_id, "bindings": bindings}) if not matched: raise RuntimeError(f"CLB target not found for {step['service']} on {step['host']} {step['private_ip']}:{step['target_port']}") if len(matched) > 1: summary = ", ".join(item["load_balancer_id"] for item in matched) raise RuntimeError(f"multiple CLB bindings discovered for {step['service']} on {step['host']}: {summary}") discovered = matched[0] step["clb_load_balancer_id"] = discovered["load_balancer_id"] step["clb_listener_ids"] = sorted({binding["listener_id"] for binding in discovered["bindings"] if binding["listener_id"]}) return {"step": step, "bindings": discovered["bindings"], "discovered": True} def modify_binding_weight(client: Any, inventory: dict[str, Any], binding: dict[str, Any], weight: int, load_balancer_id: str) -> None: payload: dict[str, Any] = { "LoadBalancerId": load_balancer_id, "ListenerId": binding["listener_id"], "Targets": [{"InstanceId": binding["instance_id"], "Port": int(binding["port"])}], "Weight": int(weight), } if binding.get("location_id"): payload["LocationId"] = binding["location_id"] response = call_clb(client, "ModifyTargetWeight", payload) wait_clb_task( client, str(response.get("RequestId") or "").strip(), timeout_seconds=int(inventory.get("clb_task_timeout_seconds") or 180), poll_seconds=int(inventory.get("clb_poll_seconds") or 2), ) def set_clb_snapshot_weight(client: Any, inventory: dict[str, Any], snapshot: dict[str, Any], weight: int | None) -> None: step = snapshot["step"] for binding in snapshot["bindings"]: target_weight = int(binding["weight"]) if weight is None else int(weight) modify_binding_weight(client, inventory, binding, target_weight, step["clb_load_balancer_id"]) def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout_seconds: int) -> str: # 远端只改当前前端服务的 docker.env 和 systemd unit,避免碰同机其他项目。 lines = [ "set -euo pipefail", f"UNIT={shlex.quote(str(service['unit']))}", f"CONTAINER={shlex.quote(str(service['container']))}", f"ENV_FILE={shlex.quote(str(service['env_file']))}", f"HEALTH_TIMEOUT={int(health_timeout_seconds)}", ] if image: lines.extend( [ f"IMAGE={shlex.quote(image)}", 'test -f "$ENV_FILE"', 'TMP_FILE="$(mktemp)"', 'awk -v image="$IMAGE" \'BEGIN{done=0} /^IMAGE=/{print "IMAGE=" image; done=1; next} {print} END{if(!done) print "IMAGE=" image}\' "$ENV_FILE" > "$TMP_FILE"', 'install -m 0640 "$TMP_FILE" "$ENV_FILE"', 'rm -f "$TMP_FILE"', 'docker pull "$IMAGE"', ] ) lines.extend( [ 'systemctl restart "$UNIT"', 'deadline=$((SECONDS + HEALTH_TIMEOUT))', 'while ! systemctl is-active --quiet "$UNIT"; do', ' if [ "$SECONDS" -ge "$deadline" ]; then', ' systemctl status "$UNIT" --no-pager || true', ' exit 20', " fi", " sleep 2", "done", 'while [ "$SECONDS" -lt "$deadline" ]; do', ' state="$(docker inspect --format \'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}\' "$CONTAINER" 2>/dev/null || true)"', ' if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then', ' docker ps --filter "name=$CONTAINER" --format "{{.Names}} {{.Status}}"', " exit 0", " fi", " sleep 2", "done", 'docker inspect "$CONTAINER" 2>/dev/null || true', 'journalctl -u "$UNIT" -n 80 --no-pager || true', "exit 21", ] ) return "\n".join(lines) + "\n" def remote_discover_script() -> str: # 只读取 hyapp 命名空间内的 unit/env/container,不扫描或修改其他项目。 return r"""set -euo pipefail echo "== hyapp units ==" systemctl list-units 'hyapp-*.service' --no-pager --all || true echo "== hyapp env files ==" if [ -d /etc/hyapp ]; then find /etc/hyapp -maxdepth 2 -name docker.env -print 2>/dev/null | sort | while read -r env_file; do echo "-- $env_file" sed -n 's/^\(IMAGE\|CONTAINER_NAME\|CONFIG_PATH\|STOP_TIMEOUT_SEC\)=/\1=/p' "$env_file" || true done else echo "/etc/hyapp missing" fi echo "== hyapp containers ==" docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true echo "== listening ports ==" ss -lntp 2>/dev/null | awk 'NR==1 || /:29200/' || true """ def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> dict[str, Any]: if dry_run: return {"ok": True, "dry_run": True, "steps": plan} tat_client = build_tat_client(inventory) clb_client = build_clb_client(inventory) seen_hosts = list(dict.fromkeys(step["host"] for step in plan)) by_host = {name: next(step for step in plan if step["host"] == name) for name in seen_hosts} hosts: list[dict[str, Any]] = [] for host_name, seed in by_host.items(): try: result = run_tat_shell( tat_client, instance_id=seed["instance_id"], script=remote_discover_script(), command_name=f"deploy-platform-discover-{host_name}", timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900), poll_seconds=int(inventory.get("tat_poll_seconds") or 2), ) hosts.append( { "host": host_name, "instance_id": seed["instance_id"], "private_ip": seed["private_ip"], "status": result["status"], "output": str(result.get("output") or "")[-5000:], } ) except Exception as exc: # noqa: BLE001 hosts.append( { "host": host_name, "instance_id": seed["instance_id"], "private_ip": seed["private_ip"], "status": "ERROR", "error": str(exc), } ) clb_bindings: list[dict[str, Any]] = [] for step in plan: if not step["clb_enabled"]: continue try: snapshot = prepare_clb_snapshot(clb_client, dict(step)) clb_bindings.append( { "service": step["service"], "host": step["host"], "load_balancer_id": snapshot["step"]["clb_load_balancer_id"], "listener_ids": snapshot["step"]["clb_listener_ids"], "bindings": snapshot["bindings"], "discovered": bool(snapshot.get("discovered")), } ) except Exception as exc: # noqa: BLE001 clb_bindings.append( { "service": step["service"], "host": step["host"], "error": str(exc), } ) return {"ok": True, "hosts": hosts, "clb": clb_bindings} def execute_plan(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool, assume_yes: bool) -> dict[str, Any]: if dry_run: return {"ok": True, "dry_run": True, "steps": plan} if not assume_yes: raise RuntimeError("real execution requires --yes") tat_client = build_tat_client(inventory) clb_client = build_clb_client(inventory) if any(step["clb_enabled"] for step in plan) else None results: list[dict[str, Any]] = [] for step in plan: started = time.time() record = dict(step) snapshot: dict[str, Any] | None = None try: if step["clb_enabled"]: snapshot = prepare_clb_snapshot(clb_client, step) set_clb_snapshot_weight(clb_client, inventory, snapshot, 0) if step["drain_seconds"] > 0: time.sleep(step["drain_seconds"]) record["clb_drained"] = True service_cfg = inventory["services"][step["service"]] script = remote_restart_script( service_cfg, image=step["image"] if step["action"] == "deploy" else "", health_timeout_seconds=int(inventory.get("service_health_timeout_seconds") or 150), ) remote = run_tat_shell( tat_client, instance_id=step["instance_id"], script=script, command_name=f"deploy-platform-{step['action']}-{step['service']}-{step['host']}", timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900), poll_seconds=int(inventory.get("tat_poll_seconds") or 2), ) record["remote_status"] = remote["status"] record["remote_output"] = str(remote.get("output") or "")[-2000:] if remote["status"] != "SUCCESS": raise RuntimeError(f"TAT command failed: {remote['status']}") if snapshot is not None: # CLB 后端健康由 CLB 自己持续探测;服务本地健康通过后恢复原权重。 set_clb_snapshot_weight(clb_client, inventory, snapshot, None) record["clb_restored"] = True stabilize = int(inventory.get("stabilize_seconds") or 0) if stabilize > 0: time.sleep(stabilize) record["status"] = "success" record["duration_seconds"] = round(time.time() - started, 2) results.append(record) except Exception as exc: # noqa: BLE001 record["status"] = "failed" record["error"] = str(exc) record["duration_seconds"] = round(time.time() - started, 2) results.append(record) return {"ok": False, "steps": results, "error": str(exc)} return {"ok": True, "steps": results} def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Deploy deploy-platform through Tencent TAT with optional CLB drain.") parser.add_argument("action", choices=["plan", "discover", "restart", "deploy"]) parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json") parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.") parser.add_argument("--services", required=True, help="Comma separated service names.") parser.add_argument("--hosts", default="", help="Optional comma separated host names for one-node maintenance.") parser.add_argument("--tag", default="", help="Release tag used by image_template during deploy.") parser.add_argument("--image", action="append", default=[], help="Override one image: service=image. Can be repeated.") parser.add_argument("--no-clb", action="store_true", help="Skip CLB drain. Only use for isolated emergency maintenance.") parser.add_argument("--dry-run", action="store_true", help="Print the execution plan without calling Tencent APIs.") parser.add_argument("--yes", action="store_true", help="Required for real restart/deploy execution.") return parser.parse_args(argv) def main(argv: list[str]) -> int: try: args = parse_args(argv) load_env_file(args.env_file) inventory = load_inventory(args.inventory) service_names = selected_service_names(inventory, args.services) overrides = image_overrides(args.image) action = "restart" if args.action == "plan" else args.action plan = build_plan( inventory, action=action, service_names=service_names, raw_hosts=args.hosts, tag=args.tag, overrides=overrides, no_clb=args.no_clb, ) if args.action == "plan": print(json.dumps({"ok": True, "steps": plan}, ensure_ascii=False, indent=2)) return 0 if args.action == "discover": result = execute_discovery(inventory, plan, dry_run=args.dry_run) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result.get("ok") else 1 result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result.get("ok") else 1 except Exception as exc: # noqa: BLE001 print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))