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>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-09 17:22:06 +00:00
committed by GitHub
parent 885fc7f099
commit a415431d93

View File

@@ -993,18 +993,20 @@ def _detect_external_drives() -> list[dict]:
# ── custom.nix Hub Managed section helpers ──────────────────────── # ── 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. """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] = {} features: dict[str, bool] = {}
nostr_npub = None nostr_npub = None
timezone = None
locale = None
try: try:
with open(CUSTOM_NIX, "r") as f: with open(CUSTOM_NIX, "r") as f:
content = f.read() content = f.read()
begin = content.find(HUB_BEGIN) begin = content.find(HUB_BEGIN)
end = content.find(HUB_END) end = content.find(HUB_END)
if begin == -1 or end == -1: if begin == -1 or end == -1:
return features, nostr_npub return features, nostr_npub, timezone, locale
section = content[begin:end] section = content[begin:end]
for m in re.finditer( for m in re.finditer(
r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', 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: if m2:
nostr_npub = m2.group(1) 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: except FileNotFoundError:
pass 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.""" """Write the Hub Managed section inside custom.nix."""
lines = [] lines = []
for feat_id, enabled in features.items(): 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};") lines.append(f" sovran_systemsOS.features.{feat_id} = lib.mkForce {val};")
if nostr_npub: if nostr_npub:
lines.append(f' sovran_systemsOS.nostr_npub = lib.mkForce "{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_block = (
HUB_BEGIN + "\n" HUB_BEGIN + "\n"
+ "\n".join(lines) + ("\n" if lines else "") + "\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: def _is_sshd_feature_enabled() -> bool:
"""Check if the sshd feature is enabled via hub overrides or config.""" """Check if the sshd feature is enabled via hub overrides or config."""
overrides, _ = _read_hub_overrides() overrides, *_ = _read_hub_overrides()
if "sshd" in overrides: if "sshd" in overrides:
return bool(overrides["sshd"]) return bool(overrides["sshd"])
config_state = _is_feature_enabled_in_config("sshd") config_state = _is_feature_enabled_in_config("sshd")
@@ -1800,7 +1818,7 @@ async def api_services():
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Read runtime feature overrides from custom.nix Hub Managed section # 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 # Cache port/firewall data once for the entire /api/services request
listening_ports, firewall_ports = await asyncio.gather( 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() 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 # Find the service config entry, preferring icon match when provided
entry = None entry = None
@@ -2250,7 +2268,7 @@ async def api_ports_health():
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Read runtime feature overrides from custom.nix Hub Managed section # 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 # Collect port requirements for enabled services only
enabled_port_requirements: list[tuple[str, str, list[dict]]] = [] enabled_port_requirements: list[tuple[str, str, list[dict]]] = []
@@ -2596,7 +2614,7 @@ async def api_backup_run(target: str = ""):
async def api_features(): async def api_features():
"""Return all toggleable features with current state and domain requirements.""" """Return all toggleable features with current state and domain requirements."""
loop = asyncio.get_event_loop() 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_path = os.path.join(DOMAINS_DIR, "sslemail")
ssl_email_configured = os.path.exists(ssl_email_path) 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") raise HTTPException(status_code=404, detail="Feature not found")
loop = asyncio.get_event_loop() 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: if req.enabled:
# Element-calling requires matrix domain # Element-calling requires matrix domain
@@ -2728,7 +2746,7 @@ async def api_features_toggle(req: FeatureToggleRequest):
except OSError: except OSError:
pass 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 # Clear the old rebuild log so the frontend doesn't pick up stale results
try: try:
@@ -3218,9 +3236,13 @@ def _get_current_timezone() -> str | None:
timeout=5, timeout=5,
) )
tz = result.stdout.strip() tz = result.stdout.strip()
return tz if tz and tz != "n/a" else None if tz and tz != "n/a":
return tz
except Exception: 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: def _get_current_locale() -> str | None:
@@ -3235,9 +3257,11 @@ def _get_current_locale() -> str | None:
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
if "LANG=" in line: if "LANG=" in line:
return line.split("LANG=", 1)[1].strip() return line.split("LANG=", 1)[1].strip()
return None
except Exception: 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") @app.get("/api/system/timezones")
@@ -3280,7 +3304,7 @@ class TimezoneRequest(BaseModel):
@app.post("/api/system/timezone") @app.post("/api/system/timezone")
async def api_system_set_timezone(req: TimezoneRequest): 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() tz = req.timezone.strip()
if not tz: if not tz:
raise HTTPException(status_code=400, detail="Timezone must not be empty.") 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.") raise HTTPException(status_code=400, detail="Invalid timezone format.")
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: features, nostr_npub, _, cur_locale = await loop.run_in_executor(None, _read_hub_overrides)
result = await loop.run_in_executor( await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, tz, cur_locale)
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}")
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") @app.get("/api/system/locales")
@@ -3324,7 +3349,7 @@ class LocaleRequest(BaseModel):
@app.post("/api/system/locale") @app.post("/api/system/locale")
async def api_system_set_locale(req: LocaleRequest): 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() locale = req.locale.strip()
if not locale: if not locale:
raise HTTPException(status_code=400, detail="Locale must not be empty.") 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}") raise HTTPException(status_code=400, detail=f"Unsupported locale: {locale}")
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: features, nostr_npub, cur_tz, _ = await loop.run_in_executor(None, _read_hub_overrides)
result = await loop.run_in_executor( await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, locale)
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}")
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 ──────────────────────────────────────── # ── Matrix user management ────────────────────────────────────────