From 72cc66df9217a24c3a00cdbf06b0f0fb3c19d5fb Mon Sep 17 00:00:00 2001 From: Media Date: Thu, 6 Aug 2026 19:26:00 -0500 Subject: [PATCH] fix: pass mqtt credentials through compose --- .env.example | 3 +++ controller/config.py | 19 +++++++++++++++++-- docker-compose.yml | 3 +++ tests/test_config_contracts.py | 26 ++++++++++++++++++++++++++ tests/test_runtime_assets.py | 12 ++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index b2f4744..536b055 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ CONTROLLER_SECRETS_DIR=/srv/video-player-controller/secrets MQTT_HOST=10.40.0.2 MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +MQTT_CLIENT_ID=yt-controller CONTROLLER_WEB_PORT=7531 # Match the owner of the host media and data directories. diff --git a/controller/config.py b/controller/config.py index acced49..9ce1a7c 100644 --- a/controller/config.py +++ b/controller/config.py @@ -116,6 +116,14 @@ def _text(value: Any, name: str, *, allow_none: bool = False) -> str | None: return value.strip() +def _optional_text(value: Any, name: str) -> str | None: + """Normalize unset/blank optional environment values to ``None``.""" + + if value is None or (isinstance(value, str) and not value.strip()): + return None + return _text(value, name) + + def _integer(value: Any, name: str, *, minimum: int = 1, maximum: int | None = None) -> int: if isinstance(value, bool): raise ConfigError(f"{name} must be an integer") @@ -167,6 +175,13 @@ def _env_value(env: Mapping[str, str], key: str, fallback: Any) -> Any: return env[key] if key in env else fallback +def _optional_env_value(env: Mapping[str, str], key: str, fallback: Any) -> Any: + value = env.get(key) + if value is None or not value.strip(): + return fallback + return value + + def load_config( path: str | Path | None = None, *, @@ -192,8 +207,8 @@ def load_config( mqtt = MqttConfig( host=_text(_env_value(env, "MQTT_HOST", mqtt_raw.get("host", "10.40.0.2")), "mqtt.host"), port=_integer(_env_value(env, "MQTT_PORT", mqtt_raw.get("port", 1883)), "mqtt.port", maximum=65535), - username=_text(_env_value(env, "MQTT_USERNAME", mqtt_raw.get("username")), "mqtt.username", allow_none=True), - password=_text(_env_value(env, "MQTT_PASSWORD", mqtt_raw.get("password")), "mqtt.password", allow_none=True), + username=_optional_text(_optional_env_value(env, "MQTT_USERNAME", mqtt_raw.get("username")), "mqtt.username"), + password=_optional_text(_optional_env_value(env, "MQTT_PASSWORD", mqtt_raw.get("password")), "mqtt.password"), client_id=_text(_env_value(env, "MQTT_CLIENT_ID", mqtt_raw.get("client_id", "yt-controller")), "mqtt.client_id"), keepalive_seconds=_integer(mqtt_raw.get("keepalive_seconds", 30), "mqtt.keepalive_seconds"), reconnect_min_seconds=_integer(mqtt_raw.get("reconnect_min_seconds", 1), "mqtt.reconnect_min_seconds"), diff --git a/docker-compose.yml b/docker-compose.yml index fc47c99..f290b0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,9 @@ services: FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:?Set FLASK_SECRET_KEY in .env} MQTT_HOST: ${MQTT_HOST:-10.40.0.2} MQTT_PORT: ${MQTT_PORT:-1883} + MQTT_USERNAME: ${MQTT_USERNAME:-} + MQTT_PASSWORD: ${MQTT_PASSWORD:-} + MQTT_CLIENT_ID: ${MQTT_CLIENT_ID:-yt-controller} PORT: 7531 YTPLAYER_DATA_BASE: /controller-data YTPLAYER_MEDIA_BASE: /mnt/Media/YTPlayer diff --git a/tests/test_config_contracts.py b/tests/test_config_contracts.py index d8f621a..fb9dcf1 100644 --- a/tests/test_config_contracts.py +++ b/tests/test_config_contracts.py @@ -96,11 +96,37 @@ class ConfigurationTests(unittest.TestCase): config = load_config(environ=env) self.assertEqual(config.mqtt.host, "broker") self.assertEqual(config.mqtt.port, 2883) + self.assertEqual(config.mqtt.username, "user") + self.assertEqual(config.mqtt.password, "pass") self.assertEqual(config.storage.media_base, Path("/media-test")) self.assertEqual(config.storage.data_base, Path("/data-test")) self.assertIsNone(config.downloads.cookie_path) self.assertEqual(config.web.port, 9000) + def test_blank_optional_mqtt_credentials_are_treated_as_unset(self): + config = load_config( + environ={ + "MQTT_USERNAME": "", + "MQTT_PASSWORD": " ", + } + ) + self.assertIsNone(config.mqtt.username) + self.assertIsNone(config.mqtt.password) + + def test_blank_compose_credentials_do_not_mask_yaml_credentials(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "config.yaml" + path.write_text( + "mqtt:\n username: yaml-user\n password: yaml-pass\n", + encoding="utf-8", + ) + config = load_config( + path, + environ={"MQTT_USERNAME": "", "MQTT_PASSWORD": ""}, + ) + self.assertEqual(config.mqtt.username, "yaml-user") + self.assertEqual(config.mqtt.password, "yaml-pass") + def test_slot_count_cannot_break_stable_identities(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "config.yaml" diff --git a/tests/test_runtime_assets.py b/tests/test_runtime_assets.py index 21ea4ae..0979938 100644 --- a/tests/test_runtime_assets.py +++ b/tests/test_runtime_assets.py @@ -213,6 +213,18 @@ class ComposeRuntimeTests(unittest.TestCase): published_targets.add(match.group(1)) self.assertIn("7531", published_targets) + def test_compose_passes_all_supported_mqtt_environment_overrides(self): + environment = _environment_map(self.service.get("environment")) + for name in ( + "MQTT_HOST", + "MQTT_PORT", + "MQTT_USERNAME", + "MQTT_PASSWORD", + "MQTT_CLIENT_ID", + ): + with self.subTest(name=name): + self.assertIn(name, environment) + def test_compose_uses_dedicated_data_media_and_cookie_mounts(self): mounts = _volume_mounts(self.service.get("volumes")) by_target = {target: (source, read_only) for source, target, read_only in mounts}