feat(config): config 디렉터리 기반 설정 관리 (--config-path/--env-file, trex-orb-v1 정합, BAT-026)
REQ-048/010 change-req. `--config` 를 이름으로 해석 + `--config-path <dir>` (기본 ./config) + resolve_config_toml(슬래시 포함 값은 하위호환 명시 경로). `.env` 를 config-dir 결정적 해석(--env-file 우선, 없으면 <config_dir>/.env, CWD 무관) + config/.env.example + .gitignore config/* 허용목록. 운영자 CLI 도 --config-path/--env-file 동일 적용. from_env() legacy 루트 .env 폴백 유지로 완전 하위호환. 단위 23 + 전체 375 passed, ruff clean, 라이브 스모크(name·path 동일). REQ-048/010 → Implemented. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,12 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
|
||||
from trex_core.runtime.config import DaemonConfig, DaemonInstanceConfig
|
||||
from trex_core.runtime.config import (
|
||||
DaemonConfig,
|
||||
DaemonInstanceConfig,
|
||||
_load_dotenv,
|
||||
resolve_config_toml,
|
||||
)
|
||||
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
@@ -42,7 +47,8 @@ def _resolve_instance(args) -> str:
|
||||
if getattr(args, "instance", None):
|
||||
return args.instance
|
||||
if getattr(args, "config", None):
|
||||
return DaemonInstanceConfig.from_toml(args.config).instance_key
|
||||
toml = resolve_config_toml(args.config, getattr(args, "config_path", None))
|
||||
return DaemonInstanceConfig.from_toml(toml).instance_key
|
||||
return topology.DEFAULT_INSTANCE_KEY
|
||||
|
||||
|
||||
@@ -248,8 +254,12 @@ def _fail(message: str, as_json: bool) -> None:
|
||||
# arg parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _add_common(p) -> None:
|
||||
p.add_argument("--config", help="per-instance TOML (canonical instance routing)")
|
||||
p.add_argument("--config", help="per-instance TOML name (resolved within --config-path) or path")
|
||||
p.add_argument("--config-path", dest="config_path", default=None,
|
||||
help="config directory (default ./config) for --config + .env")
|
||||
p.add_argument("--instance", help="instance key (shortcut; overrides --config)")
|
||||
p.add_argument("--env-file", dest="env_file", default=None,
|
||||
help="explicit .env path; overrides <config-dir>/.env")
|
||||
p.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
||||
p.add_argument("--timeout", type=float, default=_DEFAULT_TIMEOUT)
|
||||
|
||||
@@ -313,6 +323,15 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
|
||||
correlation_id, timeout) -> dict`, `confirm(prompt) -> bool`.
|
||||
"""
|
||||
args = _build_parser().parse_args(argv)
|
||||
# REQ-010 CHG-20260609-01: load the config-dir .env (or --env-file) before
|
||||
# reading env. from_env() also runs the legacy root-.env fallback; the
|
||||
# config-dir load wins (loaded first, override=False).
|
||||
_toml = (
|
||||
resolve_config_toml(args.config, getattr(args, "config_path", None))
|
||||
if getattr(args, "config", None)
|
||||
else None
|
||||
)
|
||||
_load_dotenv(_toml, getattr(args, "env_file", None))
|
||||
cfg = DaemonConfig.from_env()
|
||||
confirm = confirm or _default_confirm
|
||||
|
||||
|
||||
@@ -6,17 +6,57 @@ from dataclasses import dataclass, replace
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
"""Best-effort load of a .env file into os.environ (REQ-010).
|
||||
DEFAULT_CONFIG_DIR = "config"
|
||||
|
||||
Real OS environment variables take precedence over .env (override=False),
|
||||
matching 12-factor expectations. If python-dotenv is not installed or no
|
||||
.env exists, this is a no-op (no regression).
|
||||
|
||||
def resolve_config_toml(name: str, config_path: Optional[str] = None) -> str:
|
||||
"""Resolve `--config <name>` within a config directory (REQ-048 CHG-20260609-01).
|
||||
|
||||
config_dir = `--config-path` if given, else `./config`. `<name>` selects the
|
||||
TOML inside it; a missing `.toml` extension is appended.
|
||||
|
||||
Backward-compat: if `name` contains a path separator (e.g. `config/test.toml`),
|
||||
it is treated as an explicit path and used as-is so existing ops scripts that
|
||||
pass a path keep working.
|
||||
|
||||
resolve_config_toml("test") -> config/test.toml
|
||||
resolve_config_toml("test.toml") -> config/test.toml
|
||||
resolve_config_toml("prod", "/etc/trex") -> /etc/trex/prod.toml
|
||||
resolve_config_toml("config/test.toml") -> config/test.toml (path-form)
|
||||
"""
|
||||
if os.sep in name or (os.altsep and os.altsep in name):
|
||||
return name # explicit path (backward-compat)
|
||||
config_dir = config_path or DEFAULT_CONFIG_DIR
|
||||
filename = name if name.endswith(".toml") else f"{name}.toml"
|
||||
return os.path.join(config_dir, filename)
|
||||
|
||||
|
||||
def _load_dotenv(config_path: Optional[str] = None, env_file: Optional[str] = None) -> None:
|
||||
"""Best-effort load of a .env file into os.environ (REQ-010 / CHG-20260609-01).
|
||||
|
||||
Resolution (trex-orb-v1 aligned, deterministic & CWD-independent given a
|
||||
config_path): `env_file` (explicit) wins, else `<config_dir>/.env` next to the
|
||||
TOML. With neither (standalone `from_env()`), fall back to legacy CWD-relative
|
||||
`find_dotenv` so existing root `.env` setups keep working (backward-compat).
|
||||
|
||||
Real OS env vars always take precedence (`override=False`). python-dotenv
|
||||
missing or the resolved file absent => no-op (no regression).
|
||||
"""
|
||||
try:
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
from dotenv import load_dotenv
|
||||
except ImportError: # pragma: no cover - optional at runtime
|
||||
return
|
||||
if env_file:
|
||||
if os.path.isfile(env_file):
|
||||
load_dotenv(env_file, override=False)
|
||||
return
|
||||
if config_path:
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(config_path)), ".env")
|
||||
if os.path.isfile(path):
|
||||
load_dotenv(path, override=False)
|
||||
return
|
||||
# No config context: legacy CWD-relative discovery (backward-compat fallback).
|
||||
from dotenv import find_dotenv
|
||||
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||
|
||||
|
||||
@@ -252,13 +292,18 @@ class DaemonConfig:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_config(toml_path: str) -> "DaemonConfig":
|
||||
def from_config(toml_path: str, env_file: Optional[str] = None) -> "DaemonConfig":
|
||||
"""Build config from env + a per-instance TOML file (REQ-048 / BAT-013).
|
||||
|
||||
`instance_key` and `portfolio_hub_token` come from TOML (not .env).
|
||||
TOML `portfolio_hub_token` overrides the (env-derived) PortfolioHubConfig
|
||||
token when a portfolio-hub base_url is configured.
|
||||
|
||||
REQ-010 CHG-20260609-01: the config-dir `.env` (next to the TOML, or
|
||||
`--env-file`) is loaded first so it wins over the legacy root `.env`
|
||||
fallback loaded inside `from_env()` (both `override=False`).
|
||||
"""
|
||||
_load_dotenv(toml_path, env_file)
|
||||
base = DaemonConfig.from_env()
|
||||
inst = DaemonInstanceConfig.from_toml(toml_path)
|
||||
pf = base.portfolio_hub
|
||||
|
||||
@@ -66,7 +66,7 @@ from trex_core.application.preallocation_scheduler import DailyPreallocationSche
|
||||
from trex_core.application.snapshot_scheduler import SnapshotScheduler
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.runtime.config import DaemonConfig
|
||||
from trex_core.runtime.config import DaemonConfig, resolve_config_toml
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -375,11 +375,21 @@ def main() -> None: # pragma: no cover - process entrypoint
|
||||
parser = argparse.ArgumentParser(prog="trex-core-daemon")
|
||||
parser.add_argument(
|
||||
"--config", required=True,
|
||||
help="per-instance TOML config path (instance_key, portfolio_hub_token). "
|
||||
"Required — no default (REQ-048 / dcy-009 OQ-2).",
|
||||
help="per-instance TOML name (resolved within --config-path) or explicit "
|
||||
"path. Required — no default (REQ-048 / dcy-009 OQ-2).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config-path", default=None,
|
||||
help="config directory (default ./config) for --config name resolution "
|
||||
"and the matching .env (REQ-048/010 CHG-20260609-01).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--env-file", default=None,
|
||||
help="explicit .env path; overrides <config-dir>/.env (REQ-010 CHG-20260609-01).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
config = DaemonConfig.from_config(args.config)
|
||||
toml_path = resolve_config_toml(args.config, args.config_path)
|
||||
config = DaemonConfig.from_config(toml_path, env_file=args.env_file)
|
||||
Daemon(config).run()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user