Setting system timezone using Controller.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

This commit is contained in:
Roland 2026-03-13 15:56:21 -06:00
parent b1db71f04a
commit ccc6cf78d3
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 390 additions and 2 deletions

View file

@ -12,6 +12,9 @@ from controller.api.schemas import (
SystemActionResponse,
ThrottleActionResponse,
ThrottleStatusResponse,
TimezoneSetRequest,
TimezoneSetResponse,
TimezoneStatusResponse,
)
from controller.lib.admin import (
disable_hotspot,
@ -20,8 +23,10 @@ from controller.lib.admin import (
enable_throttle,
get_hotspot_status_dict,
get_throttle_status_dict,
get_timezone_status_dict,
reboot_system,
restart_all_services,
set_timezone,
shutdown_system,
)
from controller.lib.config import is_docker_mode
@ -81,6 +86,23 @@ async def throttle_disable() -> ThrottleActionResponse:
return ThrottleActionResponse(**result)
# --- Timezone ---
@router.get("/api/timezone/status", response_model=TimezoneStatusResponse)
async def timezone_status() -> TimezoneStatusResponse:
"""Get current system timezone."""
return TimezoneStatusResponse(**get_timezone_status_dict())
@router.post("/api/timezone/set", response_model=TimezoneSetResponse)
async def timezone_set(request: TimezoneSetRequest) -> TimezoneSetResponse:
"""Set the system timezone."""
result = set_timezone(request.timezone)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return TimezoneSetResponse(**result)
# --- System Control ---
@router.post("/api/shutdown", response_model=SystemActionResponse)

View file

@ -216,6 +216,32 @@ class RestartServicesResponse(BaseModel):
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Admin - Timezone
# ============================================================================
class TimezoneStatusResponse(BaseModel):
"""Response model for /api/timezone/status endpoint."""
available: bool = Field(description="Whether system timezone control is available")
timezone: Optional[str] = Field(default=None, description="Current system timezone (IANA)")
reason: Optional[str] = Field(default=None, description="Reason if unavailable")
class TimezoneSetRequest(BaseModel):
"""Request model for setting the system timezone."""
timezone: str = Field(description="IANA timezone string (e.g. America/Denver)")
class TimezoneSetResponse(BaseModel):
"""Response model for setting the system timezone."""
success: bool = Field(description="Whether the timezone was set")
timezone: Optional[str] = Field(default=None, description="New timezone if successful")
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Services - Status
# ============================================================================

View file

@ -9,7 +9,9 @@ import enum
import subprocess
from pathlib import Path
from controller.lib.config import is_docker_mode, get_config_value
import yaml
from controller.lib.config import is_docker_mode, get_config_value, get_media_directory
# Constants - use controller's own config instead of wrolpi module
DEFAULT_CPU_FREQUENCY = get_config_value('throttle.default_governor', 'ondemand')
@ -503,3 +505,128 @@ def _restart_self():
except Exception:
# If we can't restart ourselves, not much we can do
pass
# --- Timezone ---
def get_timezone_status_dict() -> dict:
"""
Get current system timezone status.
Returns dict matching TimezoneStatusResponse schema:
available: bool - Whether system timezone control is available
timezone: Optional[str] - Current system timezone (IANA)
reason: Optional[str] - Reason if unavailable
"""
if is_docker_mode():
return {
"available": False,
"timezone": None,
"reason": "Docker mode",
}
try:
result = subprocess.run(
["timedatectl", "show", "--property=Timezone", "--value"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return {
"available": True,
"timezone": result.stdout.strip(),
"reason": None,
}
else:
return {
"available": False,
"timezone": None,
"reason": result.stderr.strip() or "timedatectl returned an error",
}
except FileNotFoundError:
return {
"available": False,
"timezone": None,
"reason": "timedatectl not found",
}
except subprocess.TimeoutExpired:
return {
"available": False,
"timezone": None,
"reason": "timedatectl timed out",
}
except subprocess.SubprocessError as e:
return {
"available": False,
"timezone": None,
"reason": str(e),
}
def set_timezone(timezone: str) -> dict:
"""
Set the system timezone using timedatectl.
Args:
timezone: IANA timezone string (e.g. "America/Denver")
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "timezone": None, "error": "Not available in Docker mode"}
if not timezone or not timezone.strip():
return {"success": False, "timezone": None, "error": "Timezone must not be empty"}
try:
result = subprocess.run(
["timedatectl", "set-timezone", timezone],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
return {"success": True, "timezone": timezone, "error": None}
else:
return {"success": False, "timezone": None, "error": result.stderr.strip() or "Unknown error"}
except FileNotFoundError:
return {"success": False, "timezone": None, "error": "timedatectl not found"}
except subprocess.TimeoutExpired:
return {"success": False, "timezone": None, "error": "Timeout setting timezone"}
except subprocess.SubprocessError as e:
return {"success": False, "timezone": None, "error": str(e)}
def apply_timezone_from_config():
"""
Read the timezone from wrolpi.yaml and apply it to the system.
Called on Controller startup to ensure system timezone matches config.
"""
if is_docker_mode():
return
config_path = get_media_directory() / 'config' / 'wrolpi.yaml'
if not config_path.exists():
return
try:
with open(config_path) as f:
config = yaml.safe_load(f) or {}
except (IOError, yaml.YAMLError) as e:
print(f"Warning: Failed to read wrolpi.yaml for timezone: {e}")
return
timezone = config.get('timezone')
if not timezone:
return
result = set_timezone(timezone)
if result.get('success'):
print(f"Applied system timezone: {timezone}")
else:
print(f"Warning: Failed to apply system timezone '{timezone}': {result.get('error')}")

View file

@ -68,6 +68,10 @@ async def lifespan(app: FastAPI):
print("Loaded configuration from drive")
else:
print("Using default configuration (no controller.yaml found)")
# Apply timezone from wrolpi.yaml to the system.
from controller.lib.admin import apply_timezone_from_config
apply_timezone_from_config()
else:
print("Primary drive not mounted - using default configuration")

View file

@ -7,14 +7,17 @@ from unittest import mock
import pytest
from controller.lib.admin import (
apply_timezone_from_config,
disable_hotspot,
disable_throttle,
enable_hotspot,
enable_throttle,
get_hotspot_status,
get_throttle_status,
get_timezone_status_dict,
reboot_system,
restart_all_services,
set_timezone,
shutdown_system,
HotspotStatus,
GovernorStatus,
@ -279,3 +282,145 @@ class TestRestartAllServices:
assert "services" in result
# Should include controller as pending
assert result["services"]["wrolpi-controller"]["pending"] is True
class TestGetTimezoneStatus:
"""Tests for get_timezone_status_dict function."""
def test_returns_unavailable_in_docker_mode(self):
"""Should return unavailable when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = get_timezone_status_dict()
assert result["available"] is False
assert result["timezone"] is None
assert "Docker" in result["reason"]
def test_returns_timezone_from_timedatectl(self):
"""Should return the current system timezone."""
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stdout = "America/Denver\n"
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
result = get_timezone_status_dict()
assert result["available"] is True
assert result["timezone"] == "America/Denver"
assert result["reason"] is None
def test_handles_timedatectl_not_found(self):
"""Should handle timedatectl not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = get_timezone_status_dict()
assert result["available"] is False
assert "not found" in result["reason"]
def test_handles_timeout(self):
"""Should handle subprocess timeout."""
import subprocess
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("timedatectl", 5)):
result = get_timezone_status_dict()
assert result["available"] is False
assert "timed out" in result["reason"]
class TestSetTimezone:
"""Tests for set_timezone function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = set_timezone("America/Denver")
assert result["success"] is False
assert "Docker" in result["error"]
def test_sets_timezone_successfully(self):
"""Should set timezone via timedatectl."""
mock_result = mock.Mock()
mock_result.returncode = 0
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result) as mock_run:
result = set_timezone("America/Denver")
assert result["success"] is True
assert result["timezone"] == "America/Denver"
mock_run.assert_called_once_with(
["timedatectl", "set-timezone", "America/Denver"],
capture_output=True,
text=True,
timeout=10,
)
def test_handles_invalid_timezone(self):
"""Should handle timedatectl rejecting an invalid timezone."""
mock_result = mock.Mock()
mock_result.returncode = 1
mock_result.stderr = "Failed to set time zone: Invalid time zone"
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
result = set_timezone("Invalid/Timezone")
assert result["success"] is False
assert "Invalid" in result["error"]
def test_handles_empty_timezone(self):
"""Should reject empty timezone strings."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
result = set_timezone("")
assert result["success"] is False
assert "empty" in result["error"].lower()
def test_handles_timedatectl_not_found(self):
"""Should handle timedatectl not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = set_timezone("America/Denver")
assert result["success"] is False
assert "not found" in result["error"]
class TestApplyTimezoneFromConfig:
"""Tests for apply_timezone_from_config function."""
def test_skips_in_docker_mode(self):
"""Should do nothing in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
with mock.patch("controller.lib.admin.set_timezone") as mock_set:
apply_timezone_from_config()
mock_set.assert_not_called()
def test_applies_timezone_from_config(self, tmp_path):
"""Should read timezone from wrolpi.yaml and apply it."""
config_dir = tmp_path / 'config'
config_dir.mkdir()
config_file = config_dir / 'wrolpi.yaml'
config_file.write_text("timezone: America/New_York\n")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path):
with mock.patch("controller.lib.admin.set_timezone", return_value={"success": True}) as mock_set:
apply_timezone_from_config()
mock_set.assert_called_once_with("America/New_York")
def test_skips_when_no_timezone_in_config(self, tmp_path):
"""Should do nothing when timezone is not set in config."""
config_dir = tmp_path / 'config'
config_dir.mkdir()
config_file = config_dir / 'wrolpi.yaml'
config_file.write_text("download_wait: 60\n")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path):
with mock.patch("controller.lib.admin.set_timezone") as mock_set:
apply_timezone_from_config()
mock_set.assert_not_called()
def test_skips_when_config_file_missing(self, tmp_path):
"""Should do nothing when wrolpi.yaml doesn't exist."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path):
with mock.patch("controller.lib.admin.set_timezone") as mock_set:
apply_timezone_from_config()
mock_set.assert_not_called()

View file

@ -117,6 +117,38 @@ class TestRestartEndpoint:
assert "Docker" in response.json()["detail"]
class TestTimezoneStatusEndpoint:
"""Tests for /api/timezone/status endpoint."""
def test_timezone_status_returns_200(self, test_client):
"""Timezone status endpoint should return 200 OK."""
response = test_client.get("/api/timezone/status")
assert response.status_code == 200
def test_timezone_status_returns_expected_fields(self, test_client):
"""Timezone status should return available and timezone fields."""
response = test_client.get("/api/timezone/status")
data = response.json()
assert "available" in data
assert "timezone" in data
def test_timezone_status_shows_unavailable_in_docker(self, test_client_docker_mode):
"""Timezone should be unavailable in Docker mode."""
response = test_client_docker_mode.get("/api/timezone/status")
data = response.json()
assert data["available"] is False
class TestTimezoneSetEndpoint:
"""Tests for /api/timezone/set endpoint."""
def test_timezone_set_fails_in_docker(self, test_client_docker_mode):
"""Timezone set should fail in Docker mode."""
response = test_client_docker_mode.post("/api/timezone/set", json={"timezone": "America/Denver"})
assert response.status_code == 500
assert "Docker" in response.json()["detail"]
class TestOpenAPIIncludesAdminEndpoints:
"""Tests that OpenAPI documentation includes admin endpoints."""
@ -136,6 +168,13 @@ class TestOpenAPIIncludesAdminEndpoints:
assert "/api/throttle/enable" in data["paths"]
assert "/api/throttle/disable" in data["paths"]
def test_openapi_has_timezone_paths(self, test_client):
"""OpenAPI schema should include timezone paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/timezone/status" in data["paths"]
assert "/api/timezone/set" in data["paths"]
def test_openapi_has_system_control_paths(self, test_client):
"""OpenAPI schema should include system control paths."""
response = test_client.get("/openapi.json")

View file

@ -318,6 +318,7 @@ __all__ = [
'run_after',
'set_global_log_level',
'set_log_level',
'set_system_timezone',
'set_test_config',
'set_test_media_directory',
'slow_logger',
@ -1732,6 +1733,24 @@ async def aiohttp_head(url: str, timeout: int = None, headers: dict = None) -> A
yield response
CONTROLLER_URL = 'http://127.0.0.1:8087'
async def set_system_timezone(timezone: str):
"""Ask the Controller to apply the timezone to the system.
This is best-effort: if the Controller is unavailable or rejects the change,
the application-level timezone in wrolpi.yaml still takes effect."""
try:
async with aiohttp_post(f'{CONTROLLER_URL}/api/timezone/set',
json_={'timezone': timezone}, timeout=10) as resp:
if resp.status != 200:
contents = await resp.json()
logger.warning(f'Controller failed to set system timezone: {contents}')
except Exception as e:
logger.warning(f'Could not reach Controller to set system timezone: {e}')
@dataclass
class DownloadFileInfo:
"""Information about a file that can be downloaded."""

View file

@ -24,7 +24,7 @@ from wrolpi.api_utils import json_response, api_app
from wrolpi.collections.api import collection_bp
from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \
wrol_mode_check, native_only, disable_wrol_mode, enable_wrol_mode, get_global_statistics, url_strip_host, \
set_global_log_level, get_relative_to_media_directory, search_other_estimates
set_global_log_level, get_relative_to_media_directory, search_other_estimates, set_system_timezone
from wrolpi.config_api import config_bp
from wrolpi.dates import now
from wrolpi.downloader import download_manager
@ -264,6 +264,12 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
# Save config settings (hotspot control is now through Controller endpoints)
wrolpi_config.update(new_config)
# Apply timezone to the system via Controller (native deployments only).
if body.timezone is not None and not DOCKERIZED:
timezone_value = new_config.get('timezone')
if timezone_value:
await set_system_timezone(timezone_value)
return response.empty()