From c7838acbbfd388e649d2c0c076449df6efcfea01 Mon Sep 17 00:00:00 2001 From: gnickens Date: Thu, 10 Sep 2026 13:02:13 -0400 Subject: [PATCH] Fix devcontainer build, event-loop scope, and UTC timestamps Found by running the stack for the first time: - compose build context pointed outside the repo. Relative paths in .devcontainer/compose.override.yaml resolve against the project directory (the repo root), not the file's own directory, so "context: .." escaped the repository and the image could not build at all. - The devcontainers/python base image ships a yarn apt source whose signing key has rotated, failing apt-get update and the whole build. Drop that source list; we don't use yarn. - pytest-asyncio ran fixtures on a session-scoped loop while tests ran on per-function loops, so asyncmy raised "Future attached to a different loop" on every database-backed test. aiosqlite masked this; MySQL does not. Fixture loop scope now matches the test loop scope. - MySQL DATETIME stores no offset, so timestamps serialized bare and left clients guessing. Connections are pinned to UTC, so DeviceRead now attaches that offset explicitly, with a test covering it. Verified end to end against real MySQL 8.4 and Keycloak 26.7: cold start from destroyed volumes, uv sync --frozen, migrations, 41 tests, ruff, mypy --strict, and the live authorization matrix driven by real tokens. Co-Authored-By: Claude Opus 5 --- .devcontainer/Dockerfile | 7 ++++++- .devcontainer/compose.override.yaml | 8 ++++++-- pyproject.toml | 5 ++++- src/v2x_server/schemas/device.py | 14 +++++++++++++- tests/test_devices_api.py | 8 ++++++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e8d01d7..283786a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,12 @@ FROM mcr.microsoft.com/devcontainers/python:1-3.13-bookworm # `mysql` CLI for poking at the database during development. -RUN apt-get update \ +# +# The base image ships a yarn apt source whose signing key has since rotated, +# which makes `apt-get update` fail outright. We don't use yarn, so drop that +# source list rather than carrying its key. +RUN rm -f /etc/apt/sources.list.d/yarn.list \ + && apt-get update \ && apt-get install -y --no-install-recommends default-mysql-client \ && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/compose.override.yaml b/.devcontainer/compose.override.yaml index f17afdc..bf977c7 100644 --- a/.devcontainer/compose.override.yaml +++ b/.devcontainer/compose.override.yaml @@ -1,16 +1,20 @@ # Adds the development container to the stack defined in ../compose.yaml. # Merged by devcontainer.json; not useful on its own. +# +# NOTE: relative paths below resolve against the *project directory* -- the +# directory of the first compose file (the repo root), not this file's own +# directory. Hence "." rather than "..". services: app: build: - context: .. + context: . dockerfile: .devcontainer/Dockerfile command: sleep infinity ports: - "8000:8000" volumes: - - ..:/workspaces/v2x-server:cached + - .:/workspaces/v2x-server:cached # Keep the Linux venv out of the macOS/Windows bind mount. Without this, # host-side tooling and platform-specific wheels collide. - venv:/workspaces/v2x-server/.venv diff --git a/pyproject.toml b/pyproject.toml index 83f4b10..9098a63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,10 @@ ignore_missing_imports = true [tool.pytest.ini_options] asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "session" +# Must match the test loop scope (function). If fixtures run on a session loop +# while tests run on a per-function loop, asyncmy's loop-bound futures raise +# "attached to a different loop". aiosqlite hides this; MySQL does not. +asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] addopts = "-ra --strict-markers" filterwarnings = ["error"] diff --git a/src/v2x_server/schemas/device.py b/src/v2x_server/schemas/device.py index 6e4cec4..fcaaf8f 100644 --- a/src/v2x_server/schemas/device.py +++ b/src/v2x_server/schemas/device.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from v2x_server.models.device import DeviceStatus @@ -40,6 +40,18 @@ class DeviceRead(DeviceBase): created_at: dt.datetime updated_at: dt.datetime + @field_validator("created_at", "updated_at") + @classmethod + def _mark_as_utc(cls, value: dt.datetime) -> dt.datetime: + """Attach the offset MySQL cannot store. + + DATETIME columns carry no timezone, so these come back naive. Every + connection is pinned to UTC (see db/session.py), so a naive value read + from the database *is* UTC -- say so explicitly rather than emitting a + bare timestamp the client has to guess about. + """ + return value.replace(tzinfo=dt.UTC) if value.tzinfo is None else value + class DevicePage(BaseModel): items: list[DeviceRead] diff --git a/tests/test_devices_api.py b/tests/test_devices_api.py index f31ba59..138becd 100644 --- a/tests/test_devices_api.py +++ b/tests/test_devices_api.py @@ -101,6 +101,14 @@ class TestCrud: assert patched.json()["description"] == "original" assert patched.json()["name"] == created["name"] + async def test_timestamps_carry_an_explicit_utc_offset( + self, client: AsyncClient, as_user: AsUser + ) -> None: + """MySQL DATETIME has no offset; the API must not emit a bare timestamp.""" + as_user("operator") + created = (await client.post(PREFIX, json=_payload())).json() + assert created["created_at"].endswith(("Z", "+00:00")) + async def test_missing_device_is_404(self, client: AsyncClient, as_user: AsUser) -> None: as_user("viewer") response = await client.get(f"{PREFIX}/999999")