"""compose.yml 을 읽어 docker/podman run 실행 계획(JSON)을 만든다. compose 바이너리가 없는 폐쇄망 호스트에서 up-run.sh 가 쓴다. 토폴로지의 정본은 compose 파일 하나이고, 이 스크립트가 그것을 그대로 run 인자로 옮긴다 — 서비스·볼륨· 포트·환경변수를 스크립트에 두 번 적지 않기 위해서다. python3 _compose_to_plan.py <프로젝트디렉터리> [--engine docker|podman] [--overlay ...] --overlay 는 생성된 override(compose.patches.yml · compose.ports.yml)를 얹는다. compose 가 -f 를 여러 번 받았을 때처럼 서비스별 volumes·ports 를 이어 붙인다 (그 외 필드는 다루지 않는다). 서비스의 scale 값도 계획에 담아 up-run.sh 가 같은 수만큼 컨테이너를 띄운다. 환경변수는 이 프로세스의 env 에서 읽는다 (up-run.sh 가 .env 를 export 해 준다). 출력 JSON: {"project", "networks":[...], "volumes":[...], "services":{name:{...}}, "order":[...]} """ import json import os import shlex import posixpath import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: import yaml # PyYAML 이 있으면 그것을 쓴다. _load = yaml.safe_load _PARSER = "pyyaml" except ImportError: # 폐쇄망 호스트에는 없는 경우가 많다. import _miniyaml _load = _miniyaml.safe_load _PARSER = "miniyaml" # ── ${VAR} · ${VAR:-기본값} · ${VAR:?메시지} 치환 ──────────────────────────── _VAR = re.compile(r"\$(?:\{(?P[^}]*)\}|(?P[A-Za-z_][A-Za-z0-9_]*))") def interpolate(text): if not isinstance(text, str): return text def sub(m): if m.group("bare"): return os.environ.get(m.group("bare"), "") body = m.group("braced") for sep, kind in ((":?", "required"), (":-", "default"), ("-", "default")): if sep in body: name, _, arg = body.partition(sep) value = os.environ.get(name, "") if value: return value if kind == "required": raise SystemExit("FATAL: %s 가 필요하다 — %s" % (name, arg)) return arg return os.environ.get(body, "") # compose 는 $$ 를 리터럴 $ 로 본다. 치환 뒤에 되돌린다. return _VAR.sub(sub, text.replace("$$", "\0")).replace("\0", "$") def walk(node): if isinstance(node, dict): return {k: walk(v) for k, v in node.items()} if isinstance(node, list): return [walk(v) for v in node] return interpolate(node) # ── compose 필드 → run 인자 ───────────────────────────────────────────────── def as_list(value): if value is None: return [] return value if isinstance(value, list) else [value] def env_pairs(env): """environment 는 매핑이거나 KEY=VALUE 리스트다.""" if isinstance(env, dict): return ["%s=%s" % (k, "" if v is None else v) for k, v in env.items()] return [str(item) for item in as_list(env)] def resolve_mount(spec, project_dir, project, declared_volumes): """compose 의 짧은 볼륨 문법을 run 의 -v 값으로 바꾼다. 이름 있는 볼륨은 <프로젝트>_<이름> 으로, 상대 경로 bind 는 절대 경로로 바꾼다. """ parts = spec.split(":") if len(parts) < 2: return spec source, target = parts[0], parts[1] opts = parts[2:] if source in declared_volumes: source = "%s_%s" % (project, source) else: if not source.startswith("/"): source = posixpath.normpath(posixpath.join(project_dir, source)) # SELinux 를 켠 호스트(RHEL 계열)에서는 bind mount 에 z/Z 라벨이 필요하다. # compose 파일을 고치지 않고 SELINUX_LABEL=Z 로 붙인다. 이름 있는 볼륨에는 붙이지 않는다. label = os.environ.get("SELINUX_LABEL", "").strip() if label in ("z", "Z") and label not in opts: opts = opts + [label] return ":".join([source, target] + opts) def healthcheck_args(hc): """compose healthcheck → --health-* 인자.""" if not hc or hc.get("disable"): return [] test = hc.get("test") args = [] if isinstance(test, list) and test: kind, rest = test[0], test[1:] if kind == "CMD-SHELL": args += ["--health-cmd", rest[0]] elif kind == "CMD": # run 의 --health-cmd 는 문자열 하나다. exec 형태를 셸 한 줄로 옮긴다. args += ["--health-cmd", " ".join(shlex.quote(a) for a in rest)] elif kind == "NONE": return ["--no-healthcheck"] elif isinstance(test, str): args += ["--health-cmd", test] for key, flag in ( ("interval", "--health-interval"), ("timeout", "--health-timeout"), ("retries", "--health-retries"), ("start_period", "--health-start-period"), ): if hc.get(key) is not None: args += [flag, str(hc[key])] return args def duration_seconds(text): """compose 의 30m / 90s / 1h 를 초로.""" if text is None: return None text = str(text).strip() units = {"h": 3600, "m": 60, "s": 1} if text and text[-1] in units: try: return int(float(text[:-1]) * units[text[-1]]) except ValueError: return None try: return int(text) except ValueError: return None def replica_count(value, service): """Compose scale 값을 run 컨테이너 수로 바꾼다.""" value = str(value if value is not None else 1) if not re.fullmatch(r"[1-9][0-9]*", value): raise SystemExit("FATAL: %s 의 scale 은 1 이상의 정수여야 한다: %s" % (service, value)) return int(value) def apply_overlays(raw, overlay_paths): """override 파일의 services..volumes 를 본 파일 뒤에 이어 붙인다. compose 가 -f 를 여러 번 받을 때의 동작과 같다. apply-patches.sh 가 만드는 override 는 volumes 만 담으므로 그것만 병합한다. """ for path in overlay_paths: over = _load(open(path, encoding="utf-8").read()) or {} for name, svc in (over.get("services") or {}).items(): target = (raw.get("services") or {}).get(name) if target is None: raise SystemExit("FATAL: overlay 의 서비스 %s 가 compose 에 없다: %s" % (name, path)) for field in ("volumes", "ports"): extra = as_list((svc or {}).get(field)) if extra: target[field] = as_list(target.get(field)) + extra return raw def build(compose_path, project_dir, engine, overlays=()): with open(compose_path, encoding="utf-8") as stream: raw = _load(stream.read()) raw = apply_overlays(raw, overlays) project = os.environ.get("COMPOSE_PROJECT_NAME") or interpolate(raw.get("name") or "") or "compose" declared_volumes = set((raw.get("volumes") or {}).keys()) services_raw = raw.get("services") or {} net_defs = raw.get("networks") or {} # networks: 를 선언하지 않으면 compose 는 기본 네트워크 하나(<프로젝트>_default)를 만든다. if not net_defs: net_defs = {"default": {}} networks = [] for name, cfg in net_defs.items(): networks.append( { "name": "%s_%s" % (project, name), "alias": name, "internal": bool((cfg or {}).get("internal")), } ) net_by_alias = {n["alias"]: n["name"] for n in networks} default_net = networks[0]["name"] services = {} enabled = os.environ.get("LITELLM_ENABLED", "false").strip().lower() if enabled not in ("true", "false"): raise SystemExit("FATAL: LITELLM_ENABLED must be true or false") profiles = set(filter(None, os.environ.get("COMPOSE_PROFILES", "").split(","))) - {"litellm", "*"} if enabled == "true": profiles.add("litellm") for name, svc_raw in services_raw.items(): service_profiles = as_list(svc_raw.get("profiles")) if service_profiles and not profiles.intersection(service_profiles): continue svc = walk(svc_raw) opts = [] # 이미지·이름 image = svc.get("image") if not image: raise SystemExit("FATAL: 서비스 %s 에 image 가 없다 (이 번들은 빌드하지 않는다)." % name) restart = svc.get("restart") one_shot = restart == "no" or name in ("bootstrap", "minio-init") if restart and restart != "no": opts += ["--restart", str(restart)] if svc.get("init"): opts += ["--init"] if svc.get("shm_size"): opts += ["--shm-size", str(svc["shm_size"])] grace = duration_seconds(svc.get("stop_grace_period")) if grace is not None: opts += ["--stop-timeout", str(grace)] # env_file 은 compose 파일 기준 상대 경로다. for ef in as_list(svc.get("env_file")): path = ef if os.path.isabs(ef) else os.path.join(project_dir, ef) opts += ["--env-file", path] for pair in env_pairs(svc.get("environment")): opts += ["-e", pair] for port in as_list(svc.get("ports")): opts += ["-p", str(port)] for vol in as_list(svc.get("volumes")): opts += ["-v", resolve_mount(str(vol), project_dir, project, declared_volumes)] opts += healthcheck_args(svc.get("healthcheck")) # 호스트 사정으로 모든 컨테이너에 같은 옵션을 붙여야 할 때의 탈출구. # 예) AppArmor 를 강제하는 호스트: EXTRA_RUN_OPTS='--security-opt apparmor=unconfined' opts += shlex.split(os.environ.get("EXTRA_RUN_OPTS", "")) svc_nets = as_list(svc.get("networks")) entrypoint = svc.get("entrypoint") if entrypoint: if isinstance(entrypoint, list): # run 의 --entrypoint 는 값 하나만 받는다. 나머지는 명령 앞에 붙인다. opts += ["--entrypoint", entrypoint[0]] prefix = entrypoint[1:] else: opts += ["--entrypoint", entrypoint] prefix = [] else: prefix = [] command = svc.get("command") if isinstance(command, str): command = [command] command = prefix + [str(c) for c in as_list(command)] services[name] = { "name": "%s-%s" % (project, name), "alias": name, "replicas": replica_count(svc.get("scale"), name), "image": image, "opts": opts, "command": command, "networks": [net_by_alias[n] for n in svc_nets if n in net_by_alias] or [default_net], "one_shot": one_shot, "has_healthcheck": bool(svc.get("healthcheck")) and not (svc.get("healthcheck") or {}).get("disable"), "depends_on": sorted((svc.get("depends_on") or {}).keys()) if isinstance(svc.get("depends_on"), dict) else as_list(svc.get("depends_on")), } # depends_on 위상 정렬 — compose 가 해 주던 순서를 그대로 재현한다. order, seen, stack = [], set(), set() def visit(name): if name in seen: return if name in stack: raise SystemExit("FATAL: depends_on 순환: %s" % name) stack.add(name) for dep in services.get(name, {}).get("depends_on", []): if dep in services: visit(dep) stack.discard(name) seen.add(name) order.append(name) for name in services: visit(name) return { "parser": _PARSER, "engine": engine, "project": project, "project_dir": project_dir, "networks": networks, "volumes": ["%s_%s" % (project, v) for v in sorted(declared_volumes)], "services": services, "order": order, } def main(argv): if len(argv) < 3: raise SystemExit(__doc__) engine = "docker" if "--engine" in argv: engine = argv[argv.index("--engine") + 1] overlays = [argv[i + 1] for i, a in enumerate(argv) if a == "--overlay"] plan = build(argv[1], os.path.abspath(argv[2]), engine, overlays) plan["overlays"] = overlays print(json.dumps(plan, ensure_ascii=False, indent=1)) if __name__ == "__main__": main(sys.argv)