Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
56 changes: 55 additions & 1 deletion packages/nmp_platform_runner/src/nmp/platform_runner/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from collections.abc import Callable, MutableMapping
from dataclasses import dataclass, field
from importlib.resources import files
from urllib.parse import urlparse

from nmp.common.config import (
NMP_CONFIG_FILE_PATH_ENV_VAR,
Expand Down Expand Up @@ -153,6 +154,22 @@ def apply_run_environment(
Uses ``setdefault`` for NMP_BASE_URL / NMP_SERVICE_HOST / NMP_SERVICE_PORT
so that values pre-set by Helm / k8s (deployed mode) are never overwritten.
In standalone mode these variables are absent, so setdefault fills them in.

Precedence for NMP_BASE_URL: an externally-provided value (Helm / k8s) wins,
then an explicit ``platform.base_url`` *host* from the config file combined
with the actual bind port, then a value derived entirely from the bind
host/port. Seeding the host from the config file lets operators set a base
URL reachable from inside deployed agent containers (e.g. the Docker bridge
address) via config alone; without this the bind-derived loopback default
would silently shadow the configured value.

Only the host (and scheme) of the configured ``platform.base_url`` is
Comment thread
benmccown marked this conversation as resolved.
Outdated
honored — its port is replaced with the port the server actually binds. A
config that hardcodes ``:8080`` must not point internal clients (and the
embedded PDP) at 8080 when the platform is launched on another port (which
the e2e harness always does, and any ``nemo services run --port`` differing
from 8080 does); doing so leaves internal HTTP clients unable to reach the
server and the platform never becomes ready.
"""
if env is None:
env = os.environ
Expand All @@ -162,7 +179,12 @@ def apply_run_environment(
effective_port = env.setdefault("NMP_SERVICE_PORT", str(config.port))
normalized = effective_host.strip("[]")
url_host = f"[{normalized}]" if ":" in normalized else normalized
base_url = env.setdefault("NMP_BASE_URL", f"http://{url_host}:{effective_port}")
config_base_url_host = _config_file_base_url_host(config.config_path)
if config_base_url_host is not None:
default_base_url = f"http://{config_base_url_host}:{effective_port}"
Comment thread
benmccown marked this conversation as resolved.
Outdated
else:
default_base_url = f"http://{url_host}:{effective_port}"
base_url = env.setdefault("NMP_BASE_URL", default_base_url)
Comment thread
benmccown marked this conversation as resolved.
Outdated
# Embedded PDP is served from the same platform process; keep the auth client
# origin aligned with NMP_BASE_URL when services run on a non-default port.
env.setdefault("NMP_AUTH_POLICY_DECISION_POINT_BASE_URL", base_url)
Expand All @@ -179,6 +201,38 @@ def _set_or_clear_env(env: MutableMapping[str, str], name: str, values: set[str]
env.pop(name, None)


def _config_file_base_url_host(config_path: str) -> str | None:
"""Return the host of an explicit ``platform.base_url`` from the config file.

Reads the raw YAML rather than the merged config object so a value present
in the file can be told apart from the schema default (the merged config
always carries ``base_url``). Returns the host component (already bracketed
for IPv6 so it can be dropped into an ``http://<host>:<port>`` URL), or
``None`` when the file is missing, unreadable, does not set
``platform.base_url``, or the value has no parseable host.

Only the host is returned — callers pair it with the actual bind port, so a
config that hardcodes a port (e.g. ``:8080``) does not point internal
clients at the wrong port when the platform runs on a different one.
"""
try:
global_settings = Configuration.get_global_settings_from_file(config_path)
except (OSError, ValueError):
return None
platform_settings = global_settings.get("platform")
if not isinstance(platform_settings, dict):
return None
base_url = platform_settings.get("base_url")
if not isinstance(base_url, str) or not base_url:
return None
parsed = urlparse(base_url)
Comment thread
benmccown marked this conversation as resolved.
Outdated
if not parsed.hostname:
return None
# urlparse lowercases and strips IPv6 brackets from hostname; re-bracket so
# the host can be safely composed back into an http://<host>:<port> URL.
return f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname


def _connect_host_for_internal_clients(host: str) -> str:
"""Translate a bind-address into a connectable address.

Expand Down
59 changes: 58 additions & 1 deletion packages/nmp_platform_runner/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def _make_config(
sidecars: set[str] | None = None,
host: str = "0.0.0.0",
port: int = 8080,
config_path: str = "/tmp/test.yaml",
config_path: str = "/nonexistent/nmp-test-config.yaml",
) -> ResolvedRunConfiguration:
return ResolvedRunConfiguration(
services=services if services is not None else {"auth", "entities"},
Expand Down Expand Up @@ -210,3 +210,60 @@ def test_derives_base_url_from_effective_host_port(self):
}
apply_run_environment(_make_config(host="0.0.0.0", port=8080), env=env)
assert env["NMP_BASE_URL"] == "http://nemo-platform-api:9090"


class TestApplyRunEnvConfigBaseUrl:
Comment thread
benmccown marked this conversation as resolved.
"""Standalone mode with an explicit platform.base_url in the config file.

The config value's *host* must seed NMP_BASE_URL (instead of the
bind-derived loopback default) so operators can set a container-reachable
base URL via config alone — but paired with the actual bind port, not the
port hardcoded in the config. An externally-provided NMP_BASE_URL still
wins.
"""

def _write_config(self, tmp_path, body: str) -> str:
path = tmp_path / "config.yaml"
path.write_text(body, encoding="utf-8")
return str(path)

def test_seeds_base_url_host_from_config_file(self, tmp_path):
Comment thread
benmccown marked this conversation as resolved.
Outdated
config_path = self._write_config(tmp_path, "platform:\n base_url: http://172.17.0.1:8080\n")
env: dict[str, str] = {}
apply_run_environment(_make_config(host="0.0.0.0", port=8080, config_path=config_path), env=env)
assert env["NMP_BASE_URL"] == "http://172.17.0.1:8080"
# Embedded PDP base URL follows NMP_BASE_URL.
assert env["NMP_AUTH_POLICY_DECISION_POINT_BASE_URL"] == "http://172.17.0.1:8080"

def test_uses_actual_bind_port_not_config_port(self, tmp_path):
# The config hardcodes :8080 but the platform is launched on 59007
# (as the e2e harness does). NMP_BASE_URL must carry the real bind port
# so internal in-process clients can reach the server.
config_path = self._write_config(tmp_path, "platform:\n base_url: http://172.17.0.1:8080\n")
env: dict[str, str] = {}
apply_run_environment(_make_config(host="0.0.0.0", port=59007, config_path=config_path), env=env)
assert env["NMP_BASE_URL"] == "http://172.17.0.1:59007"
assert env["NMP_AUTH_POLICY_DECISION_POINT_BASE_URL"] == "http://172.17.0.1:59007"

def test_config_base_url_without_port_gets_bind_port(self, tmp_path):
config_path = self._write_config(tmp_path, "platform:\n base_url: http://172.17.0.1\n")
env: dict[str, str] = {}
apply_run_environment(_make_config(host="0.0.0.0", port=9090, config_path=config_path), env=env)
assert env["NMP_BASE_URL"] == "http://172.17.0.1:9090"
Comment thread
benmccown marked this conversation as resolved.
Outdated

def test_external_env_still_wins_over_config_file(self, tmp_path):
config_path = self._write_config(tmp_path, "platform:\n base_url: http://172.17.0.1:8080\n")
env: dict[str, str] = {"NMP_BASE_URL": "http://nemo-platform-api:8080"}
apply_run_environment(_make_config(host="0.0.0.0", port=8080, config_path=config_path), env=env)
assert env["NMP_BASE_URL"] == "http://nemo-platform-api:8080"

def test_falls_back_to_bind_derived_when_config_omits_base_url(self, tmp_path):
config_path = self._write_config(tmp_path, "platform:\n runtime: docker\n")
env: dict[str, str] = {}
apply_run_environment(_make_config(host="0.0.0.0", port=8080, config_path=config_path), env=env)
assert env["NMP_BASE_URL"] == "http://127.0.0.1:8080"

def test_falls_back_to_bind_derived_when_config_file_missing(self):
env: dict[str, str] = {}
apply_run_environment(_make_config(host="0.0.0.0", port=8080, config_path="/nonexistent/nmp.yaml"), env=env)
assert env["NMP_BASE_URL"] == "http://127.0.0.1:8080"