diff --git a/netengine/api/app.py b/netengine/api/app.py index adc4935..f915449 100644 --- a/netengine/api/app.py +++ b/netengine/api/app.py @@ -3,8 +3,11 @@ from fastapi import FastAPI from netengine.api.routes import router +from netengine.config.runtime import load_runtime_config from netengine.logs.middleware import StructuredLoggingMiddleware +load_runtime_config() + app = FastAPI( title="NetEngine Operator API", version="0.1", diff --git a/netengine/cli/main.py b/netengine/cli/main.py index e6dac25..986c791 100644 --- a/netengine/cli/main.py +++ b/netengine/cli/main.py @@ -19,6 +19,7 @@ ) from netengine.cli.env import db_url_from_env from netengine.config.loader import ConfigOverrideError, parse_dotted_overrides +from netengine.config.runtime import load_runtime_config from netengine.core.migrations import MigrationService, MigrationStatus from netengine.core.orchestrator import Orchestrator from netengine.core.state import RuntimeState, get_state_file @@ -404,8 +405,16 @@ def _wait_for_postgres_health(timeout_seconds: float = 120.0, interval: float = @click.group() -def cli() -> None: +@click.option( + "--config", + "config_file", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + envvar="NETENGINE_CONFIG", + help="Runtime app config file (or NETENGINE_CONFIG). Spec files still control world topology.", +) +def cli(config_file: Path | None) -> None: """NetEngine — spin up, reload, and tear down authority-autonomous worlds.""" + load_runtime_config(config_file) cli.add_command(doctor) diff --git a/netengine/config/runtime.py b/netengine/config/runtime.py new file mode 100644 index 0000000..5e55b3e --- /dev/null +++ b/netengine/config/runtime.py @@ -0,0 +1,63 @@ +"""Runtime application configuration helpers.""" + +import os +from pathlib import Path +from typing import Type + +from netengine.config.loader import ConfigLoader +from netengine.config.schemas import AppConfig, LoggingConfig +from netengine.logs import LogConfig, LoggerFactory + +NETENGINE_CONFIG_ENV = "NETENGINE_CONFIG" +_RUNTIME_DEFAULTS = {"spec": {"name": "runtime"}} + + +def _runtime_log_config(logging_config: LoggingConfig) -> Type[LogConfig]: + """Convert AppConfig logging settings into a LoggerFactory-compatible config class.""" + + class RuntimeLogConfig(LogConfig): + ENV = logging_config.environment + DEBUG = logging_config.debug + LOG_DIR = Path(logging_config.log_dir) + LOG_LEVEL = logging_config.log_level + RETENTION_DAYS = logging_config.retention_days + MAX_SIZE_BYTES = logging_config.max_size_bytes + SAMPLING_ENABLED = logging_config.sampling_enabled + SAMPLING_RATE = logging_config.sampling_rate + SERIALIZE_JSON = logging_config.serialize_json + INCLUDE_TRACEBACK = logging_config.include_traceback + BUFFER_SIZE = logging_config.buffer_size + + RuntimeLogConfig.LOG_DIR.mkdir(parents=True, exist_ok=True) + return RuntimeLogConfig + + +def load_app_config(config_file: str | Path | None = None) -> AppConfig: + """Load AppConfig from an explicit path or NETENGINE_CONFIG.""" + selected_file = config_file if config_file is not None else os.environ.get(NETENGINE_CONFIG_ENV) + return ConfigLoader.load_config( + AppConfig, + defaults=_RUNTIME_DEFAULTS, + config_file=selected_file, + ) + + +def configure_logging(app_config: AppConfig, *, force: bool = True) -> Type[LogConfig]: + """Apply AppConfig logging settings to the shared logger factory.""" + log_config = _runtime_log_config(app_config.logging) + if force: + LoggerFactory.shutdown() + LoggerFactory.initialize(log_config) + return log_config + + +def load_runtime_config(config_file: str | Path | None = None) -> AppConfig: + """Load runtime config and apply runtime-only settings. + + AppConfig controls process/runtime concerns such as logging. World topology + remains controlled by NetEngineSpec files passed to commands such as + ``netengine up``. + """ + app_config = load_app_config(config_file) + configure_logging(app_config) + return app_config diff --git a/tests/test_config.py b/tests/test_config.py index aaed14d..cdbb594 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -621,3 +621,49 @@ def test_unknown_profile_in_and_instance_rejected(self, temp_spec_dir: Path) -> path = self._write_spec(temp_spec_dir, data) with pytest.raises(SpecLoadError, match="unknown profile"): load_spec(path) + + +def test_runtime_config_file_applies_logging_defaults(tmp_path): + from netengine.config.runtime import load_runtime_config + from netengine.logs import LoggerFactory + + config_file = tmp_path / "app.yaml" + log_dir = tmp_path / "configured-logs" + config_file.write_text( + "logging:\n" + " environment: test\n" + " debug: false\n" + f" log_dir: {log_dir}\n" + " log_level: WARNING\n" + " serialize_json: true\n" + ) + + app_config = load_runtime_config(config_file) + + assert app_config.logging.environment == "test" + assert app_config.logging.log_level == "WARNING" + assert app_config.logging.serialize_json is True + assert log_dir.exists() + assert LoggerFactory._initialized is True + LoggerFactory.shutdown() + + +def test_cli_config_option_passes_runtime_config_to_logging(tmp_path): + from unittest.mock import patch + + from click.testing import CliRunner + + from netengine.cli import main as cli_main + + config_file = tmp_path / "app.yaml" + config_file.write_text("logging:\n log_level: ERROR\n environment: ci\n") + + with patch("netengine.config.runtime.LoggerFactory.initialize") as initialize, patch( + "netengine.config.runtime.LoggerFactory.shutdown" + ): + result = CliRunner().invoke(cli_main.cli, ["--config", str(config_file), "status"]) + + assert result.exit_code == 0 + runtime_log_config = initialize.call_args.args[0] + assert runtime_log_config.LOG_LEVEL == "ERROR" + assert runtime_log_config.ENV == "ci"