From a415431d930e633e4ab7c481dfb3529ee84c217f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:22:06 +0000 Subject: [PATCH] Fix NixOS timezone/locale: use declarative custom.nix config + nixos-rebuild instead of timedatectl/localectl Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/334ffeb7-2160-4938-bc4e-fb7693a1154f Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 134 +++++++++++++++++------------ 1 file changed, 80 insertions(+), 54 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index db0af73..9a9a9b4 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -993,18 +993,20 @@ def _detect_external_drives() -> list[dict]: # ── custom.nix Hub Managed section helpers ──────────────────────── -def _read_hub_overrides() -> tuple[dict, str | None]: +def _read_hub_overrides() -> tuple[dict, str | None, str | None, str | None]: """Parse the Hub Managed section inside custom.nix. - Returns (features_dict, nostr_npub_or_none).""" + Returns (features_dict, nostr_npub_or_none, timezone_or_none, locale_or_none).""" features: dict[str, bool] = {} nostr_npub = None + timezone = None + locale = None try: with open(CUSTOM_NIX, "r") as f: content = f.read() begin = content.find(HUB_BEGIN) end = content.find(HUB_END) if begin == -1 or end == -1: - return features, nostr_npub + return features, nostr_npub, timezone, locale section = content[begin:end] for m in re.finditer( r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', @@ -1022,12 +1024,24 @@ def _read_hub_overrides() -> tuple[dict, str | None]: ) if m2: nostr_npub = m2.group(1) + m3 = re.search( + r'time\.timeZone\s*=\s*(?:lib\.mkForce\s+)?"([^"]*)"', + section, + ) + if m3: + timezone = m3.group(1) + m4 = re.search( + r'i18n\.defaultLocale\s*=\s*(?:lib\.mkForce\s+)?"([^"]*)"', + section, + ) + if m4: + locale = m4.group(1) except FileNotFoundError: pass - return features, nostr_npub + return features, nostr_npub, timezone, locale -def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: +def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str | None = None, locale: str | None = None) -> None: """Write the Hub Managed section inside custom.nix.""" lines = [] for feat_id, enabled in features.items(): @@ -1038,6 +1052,10 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: lines.append(f" sovran_systemsOS.features.{feat_id} = lib.mkForce {val};") if nostr_npub: lines.append(f' sovran_systemsOS.nostr_npub = lib.mkForce "{nostr_npub}";') + if timezone: + lines.append(f' time.timeZone = lib.mkForce "{timezone}";') + if locale: + lines.append(f' i18n.defaultLocale = lib.mkForce "{locale}";') hub_block = ( HUB_BEGIN + "\n" + "\n".join(lines) + ("\n" if lines else "") @@ -1091,7 +1109,7 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None: def _is_sshd_feature_enabled() -> bool: """Check if the sshd feature is enabled via hub overrides or config.""" - overrides, _ = _read_hub_overrides() + overrides, *_ = _read_hub_overrides() if "sshd" in overrides: return bool(overrides["sshd"]) config_state = _is_feature_enabled_in_config("sshd") @@ -1800,7 +1818,7 @@ async def api_services(): loop = asyncio.get_event_loop() # Read runtime feature overrides from custom.nix Hub Managed section - overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides) # Cache port/firewall data once for the entire /api/services request listening_ports, firewall_ports = await asyncio.gather( @@ -1963,7 +1981,7 @@ async def api_service_detail(unit: str, icon: str | None = None): } loop = asyncio.get_event_loop() - overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + overrides, nostr_npub, *_ = await loop.run_in_executor(None, _read_hub_overrides) # Find the service config entry, preferring icon match when provided entry = None @@ -2250,7 +2268,7 @@ async def api_ports_health(): loop = asyncio.get_event_loop() # Read runtime feature overrides from custom.nix Hub Managed section - overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides) # Collect port requirements for enabled services only enabled_port_requirements: list[tuple[str, str, list[dict]]] = [] @@ -2596,7 +2614,7 @@ async def api_backup_run(target: str = ""): async def api_features(): """Return all toggleable features with current state and domain requirements.""" loop = asyncio.get_event_loop() - overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + overrides, nostr_npub, *_ = await loop.run_in_executor(None, _read_hub_overrides) ssl_email_path = os.path.join(DOMAINS_DIR, "sslemail") ssl_email_configured = os.path.exists(ssl_email_path) @@ -2675,7 +2693,7 @@ async def api_features_toggle(req: FeatureToggleRequest): raise HTTPException(status_code=404, detail="Feature not found") loop = asyncio.get_event_loop() - features, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides) if req.enabled: # Element-calling requires matrix domain @@ -2728,7 +2746,7 @@ async def api_features_toggle(req: FeatureToggleRequest): except OSError: pass - await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub) + await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, cur_locale) # Clear the old rebuild log so the frontend doesn't pick up stale results try: @@ -3218,9 +3236,13 @@ def _get_current_timezone() -> str | None: timeout=5, ) tz = result.stdout.strip() - return tz if tz and tz != "n/a" else None + if tz and tz != "n/a": + return tz except Exception: - return None + pass + # Fallback: check the Hub Managed section of custom.nix for a pending change + _, _, timezone, _ = _read_hub_overrides() + return timezone def _get_current_locale() -> str | None: @@ -3235,9 +3257,11 @@ def _get_current_locale() -> str | None: for line in result.stdout.splitlines(): if "LANG=" in line: return line.split("LANG=", 1)[1].strip() - return None except Exception: - return None + pass + # Fallback: check the Hub Managed section of custom.nix for a pending change + _, _, _, locale = _read_hub_overrides() + return locale @app.get("/api/system/timezones") @@ -3280,7 +3304,7 @@ class TimezoneRequest(BaseModel): @app.post("/api/system/timezone") async def api_system_set_timezone(req: TimezoneRequest): - """Set the system timezone using timedatectl.""" + """Set the system timezone declaratively via custom.nix and trigger a rebuild.""" tz = req.timezone.strip() if not tz: raise HTTPException(status_code=400, detail="Timezone must not be empty.") @@ -3289,25 +3313,26 @@ async def api_system_set_timezone(req: TimezoneRequest): raise HTTPException(status_code=400, detail="Invalid timezone format.") loop = asyncio.get_event_loop() - try: - result = await loop.run_in_executor( - None, - lambda: subprocess.run( - ["timedatectl", "set-timezone", tz], - capture_output=True, - text=True, - timeout=15, - ), - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() or "timedatectl failed." - raise HTTPException(status_code=500, detail=detail) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Failed to set timezone: {exc}") + features, nostr_npub, _, cur_locale = await loop.run_in_executor(None, _read_hub_overrides) + await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, tz, cur_locale) - return {"ok": True, "timezone": tz} + try: + open(REBUILD_LOG, "w").close() + except OSError: + pass + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", "--no-block", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "timezone": tz, "status": "rebuilding"} @app.get("/api/system/locales") @@ -3324,7 +3349,7 @@ class LocaleRequest(BaseModel): @app.post("/api/system/locale") async def api_system_set_locale(req: LocaleRequest): - """Set the system locale using localectl.""" + """Set the system locale declaratively via custom.nix and trigger a rebuild.""" locale = req.locale.strip() if not locale: raise HTTPException(status_code=400, detail="Locale must not be empty.") @@ -3332,25 +3357,26 @@ async def api_system_set_locale(req: LocaleRequest): raise HTTPException(status_code=400, detail=f"Unsupported locale: {locale}") loop = asyncio.get_event_loop() - try: - result = await loop.run_in_executor( - None, - lambda: subprocess.run( - ["localectl", "set-locale", f"LANG={locale}"], - capture_output=True, - text=True, - timeout=15, - ), - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() or "localectl failed." - raise HTTPException(status_code=500, detail=detail) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Failed to set locale: {exc}") + features, nostr_npub, cur_tz, _ = await loop.run_in_executor(None, _read_hub_overrides) + await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, locale) - return {"ok": True, "locale": locale} + try: + open(REBUILD_LOG, "w").close() + except OSError: + pass + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", "--no-block", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "locale": locale, "status": "rebuilding"} # ── Matrix user management ────────────────────────────────────────