mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
During onboarding the media drive is not mounted and ca.crt does not exist yet (or still holds only a throwaway pre-drive state). The cert banner still offered Download WROLPi Root CA, which 404'd. Gate the button and HTTPS cert-setup banner on drive mounted, ca.crt present, and repair not running so an existing CA on the drive can be served after mount/repair.
189 lines
5.8 KiB
Python
189 lines
5.8 KiB
Python
"""
|
|
Configuration management for the Controller.
|
|
|
|
Controller uses code-defined defaults and optionally loads overrides
|
|
from controller.yaml on the WROLPi drive after it's mounted.
|
|
"""
|
|
|
|
import copy
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from controller.defaults import DEFAULT_CONFIG
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Path to config file on the WROLPi drive
|
|
CONFIG_PATH_ON_DRIVE = Path("/media/wrolpi/config/controller.yaml")
|
|
|
|
# Runtime config - starts as defaults, updated after drive mount
|
|
_runtime_config: dict = copy.deepcopy(DEFAULT_CONFIG)
|
|
|
|
|
|
def get_config() -> dict:
|
|
"""Get the current runtime configuration."""
|
|
return _runtime_config
|
|
|
|
|
|
def get_config_value(key: str, default: Any = None) -> Any:
|
|
"""Get a specific config value by dot-notation key (e.g., 'drives.auto_mount')."""
|
|
keys = key.split(".")
|
|
value = _runtime_config
|
|
for k in keys:
|
|
if isinstance(value, dict) and k in value:
|
|
value = value[k]
|
|
else:
|
|
return default
|
|
return value
|
|
|
|
|
|
def is_docker_mode() -> bool:
|
|
"""Check if running in Docker container."""
|
|
return os.environ.get("DOCKERIZED", "").lower() == "true"
|
|
|
|
|
|
# Raspberry Pi detection
|
|
RPI_DEVICE_PATH = Path('/proc/device-tree/model')
|
|
_RPI_DEVICE_MODEL_CONTENTS = RPI_DEVICE_PATH.read_text() if RPI_DEVICE_PATH.is_file() else None
|
|
|
|
|
|
def is_rpi4() -> bool:
|
|
"""Check if running on Raspberry Pi 4."""
|
|
return 'Raspberry Pi 4' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False
|
|
|
|
|
|
def is_rpi5() -> bool:
|
|
"""Check if running on Raspberry Pi 5."""
|
|
return 'Raspberry Pi 5' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False
|
|
|
|
|
|
def is_rpi() -> bool:
|
|
"""Check if running on any Raspberry Pi."""
|
|
return is_rpi4() or is_rpi5()
|
|
|
|
|
|
def get_media_directory() -> Path:
|
|
"""Get the media directory path."""
|
|
return Path(os.environ.get("MEDIA_DIRECTORY", "/media/wrolpi"))
|
|
|
|
|
|
def is_primary_drive_mounted() -> bool:
|
|
"""Check if the primary WROLPi drive is mounted.
|
|
|
|
Checks for controller.yaml rather than just the config directory,
|
|
because generate_certificates.sh may create /media/wrolpi/config/ssl/
|
|
on the root filesystem before the real drive is mounted.
|
|
"""
|
|
return CONFIG_PATH_ON_DRIVE.exists()
|
|
|
|
|
|
def get_ca_certificate_path() -> Path:
|
|
"""Path to the persistent WROLPi Root CA on the media directory."""
|
|
return get_media_directory() / "config" / "ssl" / "ca.crt"
|
|
|
|
|
|
def is_ca_certificate_available() -> bool:
|
|
"""True when the Root CA file is present on the media directory.
|
|
|
|
On native installs the primary drive must be mounted first so a CA that
|
|
already lives on the drive is visible (and so we never advertise a
|
|
transient CA written onto the root filesystem during onboarding).
|
|
Docker always has its media volume available.
|
|
"""
|
|
if not is_docker_mode() and not is_primary_drive_mounted():
|
|
return False
|
|
return get_ca_certificate_path().is_file()
|
|
|
|
|
|
def reload_config_from_drive() -> bool:
|
|
"""
|
|
Load config from the mounted WROLPi drive and merge with defaults.
|
|
Called after the primary drive is mounted.
|
|
|
|
Returns True if config was loaded, False if no config file found.
|
|
"""
|
|
global _runtime_config
|
|
|
|
if not CONFIG_PATH_ON_DRIVE.exists():
|
|
# No config file on drive - keep defaults
|
|
return False
|
|
|
|
try:
|
|
with open(CONFIG_PATH_ON_DRIVE) as f:
|
|
drive_config = yaml.safe_load(f) or {}
|
|
except (IOError, yaml.YAMLError) as e:
|
|
# Failed to load - keep defaults
|
|
logger.warning("Failed to load controller.yaml: %s", e)
|
|
return False
|
|
|
|
# Deep merge: drive_config overrides defaults
|
|
_runtime_config = _deep_merge(copy.deepcopy(DEFAULT_CONFIG), drive_config)
|
|
return True
|
|
|
|
|
|
def save_config() -> None:
|
|
"""
|
|
Save current runtime config to the WROLPi drive.
|
|
Only saves the diff from defaults to keep the file clean.
|
|
Always writes controller.yaml (even if empty) so it serves as a
|
|
marker that the primary drive has been set up.
|
|
"""
|
|
# Check that the config directory exists (drive must be mounted)
|
|
config_dir = CONFIG_PATH_ON_DRIVE.parent
|
|
if not config_dir.exists():
|
|
raise RuntimeError("Cannot save config: primary drive not mounted")
|
|
|
|
# Calculate diff from defaults
|
|
diff = _get_config_diff(_runtime_config, DEFAULT_CONFIG)
|
|
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(CONFIG_PATH_ON_DRIVE, "w") as f:
|
|
yaml.dump(diff if diff else {}, f, default_flow_style=False, sort_keys=False)
|
|
|
|
|
|
def update_config(key: str, value: Any) -> None:
|
|
"""
|
|
Update a config value by dot-notation key.
|
|
Does not automatically save - call save_config() after updates.
|
|
"""
|
|
keys = key.split(".")
|
|
config = _runtime_config
|
|
|
|
# Navigate to parent of target key
|
|
for k in keys[:-1]:
|
|
if k not in config:
|
|
config[k] = {}
|
|
config = config[k]
|
|
|
|
# Set the value
|
|
config[keys[-1]] = value
|
|
|
|
|
|
def _deep_merge(base: dict, override: dict) -> dict:
|
|
"""Deep merge two dicts. Override values take precedence."""
|
|
result = base.copy()
|
|
for key, value in override.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
result[key] = _deep_merge(result[key], value)
|
|
else:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _get_config_diff(current: dict, default: dict) -> dict:
|
|
"""Get the difference between current config and defaults."""
|
|
diff = {}
|
|
for key, value in current.items():
|
|
if key not in default:
|
|
diff[key] = value
|
|
elif isinstance(value, dict) and isinstance(default.get(key), dict):
|
|
nested_diff = _get_config_diff(value, default[key])
|
|
if nested_diff:
|
|
diff[key] = nested_diff
|
|
elif value != default.get(key):
|
|
diff[key] = value
|
|
return diff
|