Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
96313be
fix(upgrade): make the documented repair procedure executable
homelabforge Sep 2, 2026
5b7a363
fix(upgrade): repair tools and maintenance mode were both broken on P…
homelabforge Sep 2, 2026
3f7da13
test(upgrade): exercise the published four-tool sequence, and check t…
homelabforge Sep 2, 2026
46b58da
fix(maintenance): fail closed at the telemetry writers, not at the ro…
homelabforge Sep 2, 2026
f7faaf5
fix(csv): warranty, insurance and tax CSV in both directions
homelabforge Sep 2, 2026
01a7ca3
fix(forms): replace ServiceVisitForm's native constraints instead of …
homelabforge Sep 2, 2026
877958f
feat(tires): mount periods, nullable position, and typed wear results
homelabforge Sep 2, 2026
553bf23
feat(tires): retire a tire instead of erasing its history
homelabforge Sep 2, 2026
b2c825c
feat(tires): two-phase rotation
homelabforge Sep 2, 2026
9d59e18
feat(tires): frontend for storage, mounting and per-status distance
homelabforge Sep 2, 2026
9a8276d
test(e2e): drive the tire lifecycle through a browser
homelabforge Sep 3, 2026
b9a1999
fix(tires): show distance and wear status on the card people actually…
homelabforge Sep 3, 2026
2f9c46c
test(e2e): cover rotation and retirement, and make the tire specs rer…
homelabforge Sep 3, 2026
9146ae4
fix(tires): count the odometer a user types into a tire operation
homelabforge Sep 3, 2026
29befcd
feat(tires): give rotate, retire and create-into-storage a UI
homelabforge Sep 3, 2026
132b630
feat(tires): tire sets, so a seasonal swap is one action instead of e…
homelabforge Sep 3, 2026
f406031
docs(changelog): record the tire mount-period work
homelabforge Sep 3, 2026
2674836
feat(analytics): tire readiness and wear endpoint
homelabforge Sep 3, 2026
233b314
feat(analytics): tire section on the Analytics page
homelabforge Sep 3, 2026
8eefc9b
test(csv): a standing guard against import and export naming absent a…
homelabforge Sep 3, 2026
267577e
test(forms): cover the line-item validator that replaced the native c…
homelabforge Sep 3, 2026
2ce6de6
docs(changelog): record the v3.3.0 bug batch
homelabforge Sep 3, 2026
6106c6f
feat(livelink): schema for movement-based session boundaries (migrati…
homelabforge Sep 3, 2026
4e21979
feat(livelink): decide drive sessions on movement, not on contact
homelabforge Sep 3, 2026
65f029a
feat(livelink): the SD-card path creates the sessions it describes
homelabforge Sep 3, 2026
daf6cf1
feat(livelink): a drive-gap setting, a reversal switch, and a diagnostic
homelabforge Sep 3, 2026
ae429a0
feat(livelink): reconstruct historic session boundaries, refusing by …
homelabforge Sep 3, 2026
e933013
feat(livelink): surface the boundary settings and rebuild history in …
homelabforge Sep 3, 2026
c219667
fix(livelink): the drive gap must not reach Torque sessions
homelabforge Sep 3, 2026
95c1846
test(livelink): replay the measured day, and fix two pyright narrowings
homelabforge Sep 3, 2026
62fbc39
style(tests): format the scenario test with the container's ruff
homelabforge Sep 3, 2026
9c7194d
docs(livelink): map SessionService's four responsibilities, re-stamp …
homelabforge Sep 3, 2026
306474d
feat(livelink): name the device whose movement nothing here can read
homelabforge Sep 3, 2026
1516bdf
docs(changelog): say where the no-movement device is named
homelabforge Sep 3, 2026
e464e67
chore: keep an internal hostname and a real device id out of new tests
homelabforge Sep 3, 2026
85801a7
feat(livelink): measure drive distance from the finest signal, cut th…
homelabforge Sep 4, 2026
1dad557
fix(livelink): ask whether a device is readable, not whether it has m…
homelabforge Sep 4, 2026
0d8b62f
feat(livelink): hide drives in which nothing moved, and say how many
homelabforge Sep 4, 2026
565821e
feat(vehicles): record a fuel filter, and read oil capacity in quarts
homelabforge Sep 4, 2026
4fae869
refactor(settings): put Integrations on the app's cards, and fix its …
homelabforge Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 156 additions & 8 deletions CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ COPY --from=backend-builder /usr/local/bin /usr/local/bin
COPY --from=backend-builder /app/app ./app
COPY --from=backend-builder /app/pyproject.toml ./pyproject.toml

# Copy the maintenance tools. The upgrade notes in CHANGELOG.md tell operators
# to run these against a live instance, and without this they are not in the
# image at all: every documented command failed with "can't open file".
# They import `app.*` and expect to run from this directory.
COPY --from=backend-builder /app/tools ./tools

# Copy frontend build
COPY --from=frontend-builder /app/frontend/dist ./static

Expand Down
7 changes: 7 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ def _normalize_root_path(cls, v: object) -> str:
# Database
database_url: str = "sqlite+aiosqlite:////data/mygarage.db"

# Maintenance mode. Starts the app far enough to apply migrations and no
# further: no scheduler, no MQTT subscriber, and telemetry ingest answers
# 503. It exists because the odometer repair tools must run before any new
# reading lands, and migrations run inside this app's own lifespan, so
# without it there is no window in which that instruction can be obeyed.
maintenance_mode: bool = False

# File Storage
data_dir: Path = Path("/data")
attachments_dir: Path = Path("/data/attachments")
Expand Down
12 changes: 5 additions & 7 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,11 @@ async def init_db():
# Run migrations using the migration runner
logger.info("Running database migrations...")
try:
# Convert async database URL to sync for migrations
# asyncpg -> psycopg2, aiosqlite -> sqlite
sync_url = settings.database_url
if "asyncpg" in sync_url:
sync_url = sync_url.replace("postgresql+asyncpg", "postgresql+psycopg2")
elif "aiosqlite" in sync_url:
sync_url = sync_url.replace("sqlite+aiosqlite", "sqlite")
# Convert async database URL to sync for migrations. Shared with the
# maintenance tools under backend/tools/, which need the same mapping.
from app.utils.db_url import to_sync_url

sync_url = to_sync_url(settings.database_url)

# Import and run migration runner
from app.migrations.runner import run_migrations
Expand Down
23 changes: 20 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,25 @@ async def lifespan(app: FastAPI):
logger.warning("=" * 80)

# Start scheduled background tasks (session timeouts, device offline detection, etc.)
from app.tasks.livelink_tasks import start_mqtt_subscriber, stop_mqtt_subscriber
from app.tasks.scheduled import start_scheduler, stop_scheduler

start_scheduler()
if settings.maintenance_mode:
# Migrations have run; stop here. Neither the scheduler nor the MQTT
# subscriber may write, because the whole point of the window is that
# the repair tools see the data exactly as the migration left it. A
# buffered dongle replays its backlog within seconds of the broker
# connecting, which is precisely what this prevents.
logger.warning("=" * 80)
logger.warning("MAINTENANCE MODE: scheduler and MQTT subscriber not started")
logger.warning("MAINTENANCE MODE: telemetry ingest will answer 503")
logger.warning("MAINTENANCE MODE: unset MYGARAGE_MAINTENANCE_MODE and restart when done")
logger.warning("=" * 80)
yield
logger.info("Shutting down MyGarage application (maintenance mode)...")
return

# Start MQTT subscriber if enabled
from app.tasks.livelink_tasks import start_mqtt_subscriber, stop_mqtt_subscriber
start_scheduler()

await start_mqtt_subscriber()

Expand Down Expand Up @@ -215,13 +228,17 @@ async def lifespan(app: FastAPI):
from app.middleware import (
CSRFProtectionMiddleware,
IngestBodySizeLimitMiddleware,
MaintenanceModeMiddleware,
RequestIDMiddleware,
SecurityHeadersMiddleware,
)

# Innermost: the ingest body-size guard runs closest to the app, so its 413
# still flows out through RequestID + SecurityHeaders and is fully decorated.
app.add_middleware(IngestBodySizeLimitMiddleware)
# Just outside it: in maintenance mode there is no point measuring a body we
# are about to refuse.
app.add_middleware(MaintenanceModeMiddleware)
app.add_middleware(CSRFProtectionMiddleware)
app.add_middleware(RequestIDMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
Expand Down
72 changes: 72 additions & 0 deletions backend/app/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import json
import logging
import os
import re
import uuid
from collections.abc import Awaitable, Callable, Mapping

Expand Down Expand Up @@ -238,6 +239,35 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
#: the (linear-time) normalizer. An optional Traefik `maxRequestBodyBytes` cap is
#: documented as deploy-side defense-in-depth but is not in this repo (R1-H3).
INGEST_PATH = "/api/v1/livelink/ingest"

#: Router prefixes whose handlers write telemetry. Maintenance mode closes these
#: and nothing else: the rest of the API stays reachable so an operator can watch
#: the upgrade, and /api/health keeps answering so the container healthcheck does
#: not fail the maintenance window and trigger a restart.
#: Ingest routers, closed wholesale: everything under them writes telemetry.
MAINTENANCE_CLOSED_PREFIXES = ("/api/v1/livelink", "/api/v1/torque")

#: Individual admin routes that write telemetry. The admin router as a whole
#: must stay OPEN -- the operator uses it to watch the repair and to turn
#: maintenance mode back off -- so these are matched exactly rather than by
#: prefix. `POST /api/livelink/devices/{id}/backfill` reaches `bulk_backfill`.
_MAINTENANCE_CLOSED_ADMIN_RE = re.compile(r"^/api/livelink/devices/[^/]+/backfill/?$")


def is_maintenance_closed(path: str) -> bool:
"""Whether maintenance mode should refuse a request to ``path``.

Args:
path: The request path, without query string.

Returns:
True if the path can write telemetry and must be refused with 503.
"""
if path.startswith(MAINTENANCE_CLOSED_PREFIXES):
return True
return _MAINTENANCE_CLOSED_ADMIN_RE.match(path) is not None


INGEST_MAX_BODY_BYTES = 256 * 1024


Expand Down Expand Up @@ -331,6 +361,48 @@ def _get_header(scope: Scope, name: bytes) -> str | None:
return None


class MaintenanceModeMiddleware:
"""Refuse telemetry ingest with 503 while the instance is in maintenance mode.

The upgrade procedure for the odometer repair tools requires that no new
reading lands between the migration and the repair. Migrations run inside
this app's own lifespan and the MQTT toggle is a database row, so stopping
ingest from outside the process is not possible; this closes it from inside.

``settings.maintenance_mode`` is read per request rather than captured at
construction, so the gate can be flipped in tests without rebuilding the app.
The cost is one attribute read per request on a boolean that is almost always
False.
"""

def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
from app.config import settings

if scope["type"] != "http" or not settings.maintenance_mode:
await self.app(scope, receive, send)
return

path = scope.get("path", "")
if not is_maintenance_closed(path):
await self.app(scope, receive, send)
return

logger.info("Maintenance mode: refused ingest request to %s", path)
await _send_json(
send,
status=503,
payload={
"detail": (
"MyGarage is in maintenance mode and is not accepting telemetry. "
"Readings buffered on the device will be delivered once it exits."
)
},
)


async def _send_json(send: Send, *, status: int, payload: Mapping[str, object]) -> None:
"""Emit a JSON response from inside ASGI middleware without recursing.

Expand Down
Loading
Loading