Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions netengine/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion netengine/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions netengine/config/runtime.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"