13 Commits
Author SHA1 Message Date
naturallaw777 ff70668f38 Merge remote-tracking branch 'github/main' into staging-dev 2026-07-13 12:46:51 -05:00
Sovran SystemsandGitHub fbe431555e Merge pull request #320 from naturallaw777/copilot/update-hub-workflow-to-stage-os-updates
Stage Hub full OS updates for next reboot (boot-first) instead of live switch
2026-07-13 17:46:35 +00:00
copilot-swe-agent[bot]andGitHub bd4c4f8716 Adjust update status docstring ordering 2026-07-13 17:41:36 +00:00
copilot-swe-agent[bot]andGitHub 201cdb5bf9 Polish staged-update test and log header formatting 2026-07-13 17:40:29 +00:00
copilot-swe-agent[bot]andGitHub 7ba4adb376 Stage Hub full updates for next reboot 2026-07-13 17:39:14 +00:00
copilot-swe-agent[bot]andGitHub a4fc880c38 Initial plan 2026-07-13 17:36:29 +00:00
naturallaw777 edf6294315 Update nixpkgs 2026-07-04 11:25:52 -05:00
Sovran SystemsandGitHub b4dd074894 Merge pull request #319 from naturallaw777/copilot/implement-desktop-only-safety-fixes
fix(desktop): harden Desktop Only role — nix-bitcoin compat, force-off server services, conditional Caddy
2026-07-03 23:39:22 +00:00
copilot-swe-agent[bot]andGitHub e81f6643fc fix: desktop-only safety fixes (nix-bitcoin compat, mkForce, conditional caddy)
1. Add nix-bitcoin.generateSecrets = lib.mkDefault true global compat
   default in role-logic.nix so Desktop Only systems can evaluate while
   nix-bitcoin is still globally imported by the flake.

2. Harden Desktop Only role: change all server/node service and feature
   disables from lib.mkDefault false to lib.mkForce false so they cannot
   be overridden by custom.nix or option defaults.
   - sovran_systemsOS.services: synapse, bitcoin, vaultwarden, wordpress, nextcloud
   - sovran_systemsOS.features: haven, mempool, element-calling, bitcoin-core
   - sovran_systemsOS.web.btcpayserver

3. Make Caddy conditional in caddy.nix:
   enable = needsHttpsPorts || extraVhosts != ""
   so Caddy does not run on Desktop Only installs with no web services.
2026-07-03 23:35:30 +00:00
copilot-swe-agent[bot]andGitHub afe51c4abd Initial plan 2026-07-03 23:33:55 +00:00
Sovran SystemsandGitHub a101e73c0e Merge pull request #318 from naturallaw777/copilot/fix-template-response-error
Fix TemplateResponse calls for Starlette 1.1.0+ compatibility
2026-06-30 17:38:27 +00:00
copilot-swe-agent[bot]andGitHub 1a5c6aca08 Fix TemplateResponse calls to use Starlette 1.1.0+ keyword-argument style 2026-06-30 17:37:21 +00:00
copilot-swe-agent[bot]andGitHub 00f76f33fd Initial plan 2026-06-30 17:35:17 +00:00
7 changed files with 232 additions and 73 deletions
+29 -16
View File
@@ -1219,7 +1219,7 @@ def _generate_qr_base64(data: str) -> str | None:
# ── Update helpers (file-based, no systemctl) ────────────────────
def _read_update_status() -> str:
"""Read the status file. Returns RUNNING, SUCCESS, FAILED, or IDLE."""
"""Read the status file. Returns RUNNING, SUCCESS, REBOOT_REQUIRED, FAILED, or IDLE."""
try:
with open(UPDATE_STATUS, "r") as f:
return f.read().strip()
@@ -1950,10 +1950,13 @@ def _verify_support_removed() -> bool:
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse("login.html", {
"request": request,
return templates.TemplateResponse(
request=request,
name="login.html",
context={
"asset_version": ASSET_VERSION,
})
},
)
@app.get("/auto-login")
@@ -2018,20 +2021,26 @@ async def api_logout(request: Request):
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"asset_version": ASSET_VERSION,
})
},
)
@app.get("/onboarding", response_class=HTMLResponse)
async def onboarding(request: Request):
_ensure_onboarding_reopened_for_migration()
return templates.TemplateResponse("onboarding.html", {
"request": request,
return templates.TemplateResponse(
request=request,
name="onboarding.html",
context={
"asset_version": ASSET_VERSION,
"onboarding_js_hash": _ONBOARDING_JS_HASH,
})
},
)
@app.get("/api/onboarding/status")
@@ -4746,17 +4755,21 @@ def _recover_stale_status(status_file: str, log_file: str, unit_name: str) -> bo
except Exception:
pass
new_status = "SUCCESS" if unit_result == "success" else "FAILED"
if unit_result == "success":
new_status = "REBOOT_REQUIRED" if unit_name == UPDATE_UNIT else "SUCCESS"
else:
new_status = "FAILED"
try:
with open(status_file, "w") as f:
f.write(new_status)
except OSError:
pass
msg = (
"\n[Update completed successfully while the server was restarting.]\n"
if new_status == "SUCCESS"
else "\n[Update encountered an error. See log above for details.]\n"
)
if new_status == "REBOOT_REQUIRED":
msg = "\n[Update staged successfully while the server was restarting. Reboot required.]\n"
elif new_status == "SUCCESS":
msg = "\n[Update completed successfully while the server was restarting.]\n"
else:
msg = "\n[Update encountered an error. See log above for details.]\n"
try:
with open(log_file, "a") as f:
f.write(msg)
+44
View File
@@ -0,0 +1,44 @@
import unittest
from pathlib import Path
HUB_NIX = Path(__file__).resolve().parents[2] / "modules" / "core" / "sovran-hub.nix"
def _section(source: str, start: str, end: str) -> str:
start_idx = source.find(start)
if start_idx == -1:
raise AssertionError(f"Expected section start not found: {start!r}")
end_idx = source.find(end, start_idx)
if end_idx == -1:
raise AssertionError(f"Expected section end not found: {end!r}")
return source[start_idx:end_idx]
class HubUpdateBootStagingTests(unittest.TestCase):
def setUp(self):
self.source = HUB_NIX.read_text()
self.update_section = _section(
self.source,
'update-script = pkgs.writeShellScript "sovran-hub-update.sh" \'\'',
"# ── Rebuild wrapper script",
)
self.rebuild_section = _section(
self.source,
'rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" \'\'',
"# ── Brave launcher wrapper",
)
def test_full_update_uses_boot_not_switch(self):
self.assertIn("nixos-rebuild boot --flake /etc/nixos", self.update_section)
self.assertNotIn("nixos-rebuild switch --flake /etc/nixos", self.update_section)
def test_full_update_marks_reboot_required(self):
self.assertIn('echo "REBOOT_REQUIRED" > "$STATUS"', self.update_section)
def test_rebuild_path_keeps_switch_semantics(self):
self.assertIn("nixos-rebuild switch --flake /etc/nixos", self.rebuild_section)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,99 @@
"""Regression test for Starlette 1.1.0+ TemplateResponse keyword-argument style.
Prior to this fix, the three HTML routes called:
templates.TemplateResponse("name.html", {"request": request, ...})
which passes the context dict as the second positional argument. With the
updated Starlette/FastAPI versions shipped in NixOS unstable (Starlette 1.1.0,
FastAPI 0.136.3) that positional argument is the template name, causing Jinja2
to receive a dict as a cache key and raise:
TypeError: unhashable type: 'dict'
The fix updates every call to use keyword arguments:
templates.TemplateResponse(request=request, name="name.html", context={...})
"""
import ast
import unittest
from pathlib import Path
SERVER_PY = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "server.py"
def _template_response_calls(source: str):
"""Return a list of ast.Call nodes that are TemplateResponse calls."""
tree = ast.parse(source)
calls = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "TemplateResponse":
calls.append(node)
return calls
class TemplateResponseSignatureTests(unittest.TestCase):
def setUp(self):
self.source = SERVER_PY.read_text()
self.calls = _template_response_calls(self.source)
def test_at_least_one_template_response_call_found(self):
self.assertGreater(len(self.calls), 0, "No TemplateResponse calls found in server.py")
def test_no_old_style_positional_dict_context(self):
"""No TemplateResponse call should pass a dict literal as its second positional arg.
The old style was:
templates.TemplateResponse("name.html", {"request": request, ...})
where args[0] is a string and args[1] is a Dict node. That pattern
triggers the Starlette 1.1.0 bug.
"""
for call in self.calls:
positional = call.args
if len(positional) >= 2 and isinstance(positional[1], ast.Dict):
self.fail(
f"Found old-style TemplateResponse call at line {call.lineno}: "
"second positional argument is a dict literal. "
"Use keyword arguments (request=, name=, context=) instead."
)
def test_request_not_duplicated_in_context(self):
"""The 'request' key must not appear inside the context= dict when
request= is already passed as a dedicated keyword argument."""
for call in self.calls:
kw_dict = {kw.arg: kw.value for kw in call.keywords if isinstance(kw, ast.keyword)}
if "request" not in kw_dict:
continue # no request= kwarg, nothing to check
context_node = kw_dict.get("context")
if not isinstance(context_node, ast.Dict):
continue
for key_node in context_node.keys:
if isinstance(key_node, ast.Constant) and key_node.value == "request":
self.fail(
f"TemplateResponse at line {call.lineno} passes 'request' both as "
"request= keyword argument and inside the context dict."
)
def test_all_calls_use_keyword_arguments(self):
"""Every TemplateResponse call should use keyword arguments for request, name,
and context rather than relying on positional ordering."""
for call in self.calls:
kw_args = {kw.arg for kw in call.keywords if isinstance(kw, ast.keyword)}
self.assertIn(
"request",
kw_args,
f"TemplateResponse at line {call.lineno} is missing keyword argument 'request='.",
)
self.assertIn(
"name",
kw_args,
f"TemplateResponse at line {call.lineno} is missing keyword argument 'name='.",
)
if __name__ == "__main__":
unittest.main()
Generated
+21 -21
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1781968640,
"narHash": "sha256-ySgKs6YZRJRsBVoMnmOBGQiNRaX7M9FxMnHA29kovqA=",
"lastModified": 1783086783,
"narHash": "sha256-NxXpNF/9tq2nI+SxFHUxjro3u11SF3l4vs7bawdMKkQ=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "3959faadf6c9d3148e1d819ae85b7f32da5b005b",
"rev": "4f6d07cae877ef58f0fbc9e731c99800ddb80859",
"type": "github"
},
"original": {
@@ -52,11 +52,11 @@
]
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
@@ -108,11 +108,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1780218263,
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=",
"lastModified": 1782911660,
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0",
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
"type": "github"
},
"original": {
@@ -139,11 +139,11 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1782691344,
"narHash": "sha256-i5nw9BYYsMDAaOC4J+JmTof6b2GhlyH076awYRNrTV8=",
"lastModified": 1782999065,
"narHash": "sha256-5Dgj5+pIQYZKrXUGaLCk7CKfN3MmpwIhO94++WVxvng=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1f01958ffb5b3545c96d9ef2f4e24c5e5e1eb846",
"rev": "80d591ed473cfc46329932c2aadac9b435342c7c",
"type": "github"
},
"original": {
@@ -187,11 +187,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"lastModified": 1782959384,
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
"type": "github"
},
"original": {
@@ -203,11 +203,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1781607440,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
"lastModified": 1782948114,
"narHash": "sha256-AXmz9ho4Lud5CsbrZsuSVwpQZ4o5FgZ1chxBn5cJ8+0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
"rev": "9e92285f211dad236540fd617d7e30e0b99bc0e1",
"type": "github"
},
"original": {
@@ -224,11 +224,11 @@
"systems": "systems_2"
},
"locked": {
"lastModified": 1782706849,
"narHash": "sha256-b2ODGxKD3hgrwuesLtDomp2DKF3UiOn3+EBTDXpcqGo=",
"lastModified": 1783173302,
"narHash": "sha256-nlnOw/zsD2H2NHSZ5oNWwcjuM17vipyAapfXsO78GjY=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "c6a99bb41fb0f37e03ca38b1d81b006e98e3bf1a",
"rev": "a402fdf2a1ef297d8ea7c95b90d6af0dbe90ab11",
"type": "github"
},
"original": {
+4 -1
View File
@@ -16,7 +16,10 @@ let
in
{
services.caddy = {
enable = true;
# Only enable Caddy when at least one domain-based service needs it or
# the operator has defined custom vhosts. This prevents Caddy from
# running on Desktop Only installs that have no web services configured.
enable = needsHttpsPorts || extraVhosts != "";
user = "caddy";
group = "root";
};
+22 -6
View File
@@ -3,6 +3,13 @@
{
config = lib.mkMerge [
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
# Only systems can evaluate without enabling any Bitcoin services.
{
nix-bitcoin.generateSecrets = lib.mkDefault true;
}
# ── Server+Desktop Role (default) ─────────────────────────
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
@@ -12,15 +19,24 @@
(lib.mkIf config.sovran_systemsOS.roles.desktop {
services.desktopManager.gnome.enable = true;
# Force all server/node services and features off so they cannot be
# accidentally enabled via custom.nix or option defaults on Desktop Only.
sovran_systemsOS.services = {
synapse = lib.mkDefault false;
bitcoin = lib.mkDefault false;
vaultwarden = lib.mkDefault false;
wordpress = lib.mkDefault false;
nextcloud = lib.mkDefault false;
synapse = lib.mkForce false;
bitcoin = lib.mkForce false;
vaultwarden = lib.mkForce false;
wordpress = lib.mkForce false;
nextcloud = lib.mkForce false;
};
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
sovran_systemsOS.features = {
haven = lib.mkForce false;
mempool = lib.mkForce false;
element-calling = lib.mkForce false;
bitcoin-core = lib.mkForce false;
};
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
})
# ── Bitcoin Node Only Role ────────────────────────────────
+9 -25
View File
@@ -146,33 +146,16 @@ let
echo ""
if [ "$RC" -eq 0 ]; then
echo " Step 2/3: nixos-rebuild "
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
echo " Step 2/3: nixos-rebuild boot (stage next reboot) "
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1)
SWITCH_RC=$?
echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then
echo "[OK] switch succeeded"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
echo ""
echo " Build succeeded a reboot is required to apply this update"
echo " (Critical system components changed; running nixos-rebuild boot instead)"
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1; then
echo "REBOOT_REQUIRED" > "$STATUS"
exit 0
else
echo "[ERROR] nixos-rebuild boot also failed"
RC=1
fi
else
echo "[ERROR] nixos-rebuild switch failed"
BOOT_RC=$?
echo "$BOOT_OUT"
if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed"
RC=1
fi
echo ""
@@ -188,9 +171,10 @@ let
if [ "$RC" -eq 0 ]; then
echo ""
echo " Update completed successfully"
echo " Update staged successfully"
echo " Reboot required to activate the new system"
echo ""
echo "SUCCESS" > "$STATUS"
echo "REBOOT_REQUIRED" > "$STATUS"
else
echo ""
echo " Update failed see errors above"