Skip to content

Commit 1e601a3

Browse files
authored
Merge pull request #166 from FreeshardBase/fix/clayde/traefik-config-skip-broken-apps
fix(startup): never let one broken app row block core boot
2 parents e870ba2 + 5a0bb91 commit 1e601a3

3 files changed

Lines changed: 99 additions & 11 deletions

File tree

.claude/skills/freeshard-architecture-contract/SKILL.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,15 @@ The four status **allow-lists** live in shard_core/service/app_tools.py:
145145

146146
Plus two **exclusion filters**:
147147

148-
- `write_traefik_dyn_config` skips INSTALLATION_QUEUED and ERROR apps (app_installation/util.py:108,113)
148+
- `write_traefik_dyn_config` skips apps in `_NOT_ROUTED_STATUS` = INSTALLATION_QUEUED, UNINSTALLING, ERROR (app_installation/util.py:23-28,115). INSTALLING is deliberately NOT in that set: `_install_app_from_zip` writes the traefik config while the status is still INSTALLING, and no further write follows before STOPPED — filtering it would leave freshly installed apps unrouted (worker.py:118-119,184)
149149
- `control_apps` (idle lifecycle) skips INSTALLATION_QUEUED and INSTALLING (service/app_lifecycle.py:45)
150150

151151
**RULE: when adding or repurposing a status, audit all six sites above.** A status
152152
missing from an allow-list silently no-ops (`log.debug` + skip); a status missing from
153-
the exclusion filters gets routed/lifecycled while half-installed —
154-
`write_traefik_dyn_config` calling `get_app_metadata()` on an app with missing files
155-
raises `MetadataNotFound` inside the lifespan (app_factory.py:83) → potential boot loop.
153+
the exclusion filters gets routed/lifecycled while half-installed. Since #158,
154+
`write_traefik_dyn_config` loads each app's metadata in `try/except` and skips the app
155+
with a `log.warning` — a missing/malformed `app_meta.json` no longer raises
156+
`MetadataNotFound` out of the lifespan (app_factory.py:83), so it can no longer boot-loop core.
156157

157158
## 5. Signals discipline (shard_core/util/signals.py, blinker)
158159

@@ -232,7 +233,7 @@ All verified 2026-07-03:
232233
| Weakness | Evidence | Status |
233234
|---|---|---|
234235
| No startup reconciliation of stranded *_QUEUED / INSTALLING rows; a crash mid-install strands the row forever (manual uninstall works because `_uninstall_app` asserts nothing) | lifespan app_factory.py:78-108 has no such step; worker.py:124-128 | Open, no issue filed |
235-
| Stranded INSTALLING/UNINSTALLING row + missing files → `MetadataNotFound` raised uncaught in lifespan step 3 → boot loop | app_installation/util.py:110-114 filters only INSTALLATION_QUEUED and ERROR | Open |
236+
| Stranded INSTALLING/UNINSTALLING row + missing files → `MetadataNotFound` raised uncaught in lifespan step 3 → boot loop | app_installation/util.py:117-124 now loads metadata per app in `try/except` and skips with a warning | Fixed 2026-07-14, https://github.com/FreeshardBase/freeshard/issues/158 |
236237
| backup.py blocks the event loop: sync `subprocess.run(["rclone","obscure",...])` (backup.py:186-188) and sync Azure `BlobClient.upload_blob` marker write (backup.py:133-151); rclone command built via `str.split()` breaks on whitespace in SAS URL/paths (backup.py:169) | file:lines cited | Open |
237238
| Backup task is fire-and-forget with no strong reference — `task` is a local var and the done-callback spawns another unreferenced task (asyncio weak-ref GC footgun); contrast the correct `background_tasks` set pattern in app_lifecycle.py:32-36 | backup.py:62-78 | Open |
238239
| rclone exit code never checked — success inferred from parsing the last stderr line as JSON; a failure ending in valid JSON records as success, a non-JSON last line raises `JSONDecodeError` | backup.py:168-181 | Open; known trouble spot (repeated churn: commits b9dfcfd, 935250b, d6560a0) |

shard_core/service/app_installation/util.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020

2121
log = logging.getLogger(__name__)
2222

23+
# apps in these states have no complete installation on disk to route to
24+
_NOT_ROUTED_STATUS = {
25+
Status.INSTALLATION_QUEUED.value,
26+
Status.UNINSTALLING.value,
27+
Status.ERROR.value,
28+
}
29+
2330

2431
async def get_app_from_db(app_name: str) -> InstalledApp:
2532
async with db_conn() as conn:
@@ -105,13 +112,16 @@ async def write_traefik_dyn_config():
105112
async with db_conn() as conn:
106113
all_apps = await db_installed_apps.get_all(conn)
107114
installed_apps = [
108-
InstalledApp(**a) for a in all_apps if a["status"] != Status.INSTALLATION_QUEUED
109-
]
110-
app_infos = [
111-
AppInfo(get_app_metadata(a.name), installed_app=a)
112-
for a in installed_apps
113-
if a.status != Status.ERROR
115+
InstalledApp(**a) for a in all_apps if a["status"] not in _NOT_ROUTED_STATUS
114116
]
117+
app_infos = []
118+
for app in installed_apps:
119+
try:
120+
app_infos.append(AppInfo(get_app_metadata(app.name), installed_app=app))
121+
except Exception as e:
122+
log.warning(
123+
f"skipping app {app.name} in traefik config, its metadata could not be loaded: {e!r}"
124+
)
115125

116126
async with db_conn() as conn:
117127
default_row = await db_identities.get_default(conn)

tests/test_traefik_dyn_spec.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
import json
2+
import logging
13
from pathlib import Path
24

35
import yaml
46

7+
from shard_core.data_model.app_meta import InstallationReason, Status
8+
from shard_core.database import installed_apps as db_installed_apps
9+
from shard_core.database.connection import db_conn
10+
from shard_core.service.app_installation.util import write_traefik_dyn_config
11+
from shard_core.service.app_tools import get_installed_apps_path
512
from shard_core.settings import settings
613

714

@@ -46,3 +53,73 @@ async def test_template_is_written(api_client):
4653
"immich_http",
4754
}
4855
assert out_routers_http["filebrowser_http"]["service"] == "filebrowser_http"
56+
57+
58+
def _write_app_meta(app_name: str):
59+
app_path = get_installed_apps_path() / app_name
60+
app_path.mkdir(parents=True, exist_ok=True)
61+
(app_path / "app_meta.json").write_text(
62+
json.dumps(
63+
{
64+
"v": "1.0",
65+
"app_version": "0.1.0",
66+
"name": app_name,
67+
"pretty_name": app_name,
68+
"icon": "icon.svg",
69+
"entrypoints": [
70+
{
71+
"container_name": app_name,
72+
"container_port": 80,
73+
"entrypoint_port": "http",
74+
}
75+
],
76+
"paths": {"": {"access": "public"}},
77+
}
78+
)
79+
)
80+
81+
82+
async def _add_app_to_db(app_name: str, status: Status):
83+
async with db_conn() as conn:
84+
await db_installed_apps.insert(
85+
conn,
86+
{
87+
"name": app_name,
88+
"installation_reason": InstallationReason.CUSTOM.value,
89+
"status": status.value,
90+
"last_access": None,
91+
},
92+
)
93+
94+
95+
def _read_traefik_dyn_config() -> dict:
96+
with open(
97+
Path(settings().path_root) / "core" / "traefik_dyn" / "traefik_dyn.yml", "r"
98+
) as f:
99+
return yaml.safe_load(f)
100+
101+
102+
async def test_app_with_missing_metadata_is_skipped(app_client, memory_logger):
103+
_write_app_meta("healthy_app")
104+
await _add_app_to_db("healthy_app", Status.RUNNING)
105+
await _add_app_to_db("broken_app", Status.RUNNING)
106+
107+
await write_traefik_dyn_config()
108+
109+
routers = _read_traefik_dyn_config()["http"]["routers"]
110+
assert "healthy_app_http" in routers
111+
assert "broken_app_http" not in routers
112+
assert [
113+
r
114+
for r in memory_logger.records
115+
if r.levelno == logging.WARNING and "broken_app" in r.getMessage()
116+
]
117+
118+
119+
async def test_uninstalling_app_is_not_routed(app_client):
120+
_write_app_meta("uninstalling_app")
121+
await _add_app_to_db("uninstalling_app", Status.UNINSTALLING)
122+
123+
await write_traefik_dyn_config()
124+
125+
assert "uninstalling_app_http" not in _read_traefik_dyn_config()["http"]["routers"]

0 commit comments

Comments
 (0)