feat(cloud-store): support SQLite and PostgreSQL engines
This commit is contained in:
@@ -1,8 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from cloud.control_config import CloudControlConfig, load_control_config
|
||||
from cloud.database import CloudDatabase
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
DatabaseFactory = Callable[[CloudControlConfig], CloudDatabase]
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
config: CloudControlConfig | None = None,
|
||||
database_factory: DatabaseFactory | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create the independently deployable cloud API application."""
|
||||
return FastAPI(title="Device Cloud API")
|
||||
control_config = config or load_control_config()
|
||||
build_database = database_factory or _default_database_factory
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
database = build_database(control_config)
|
||||
app.state.cloud_config = control_config
|
||||
app.state.database = database
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
return FastAPI(title="Device Cloud API", lifespan=lifespan)
|
||||
|
||||
|
||||
def _default_database_factory(config: CloudControlConfig) -> CloudDatabase:
|
||||
return CloudDatabase(
|
||||
config.database_url,
|
||||
create_schema=config.environment != "production",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cloud_api.app import create_app
|
||||
from cloud.control_config import CloudControlConfig
|
||||
|
||||
|
||||
def test_create_app_returns_independent_cloud_application() -> None:
|
||||
@@ -8,3 +11,23 @@ def test_create_app_returns_independent_cloud_application() -> None:
|
||||
|
||||
assert app.title == "Device Cloud API"
|
||||
assert callable(create_app)
|
||||
|
||||
|
||||
def test_cloud_application_owns_database_lifecycle() -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class FakeDatabase:
|
||||
def close(self) -> None:
|
||||
events.append("closed")
|
||||
|
||||
fake_database = FakeDatabase()
|
||||
app = create_app(
|
||||
config=CloudControlConfig(database_url="sqlite:///:memory:"),
|
||||
database_factory=lambda _config: fake_database, # type: ignore[arg-type,return-value]
|
||||
)
|
||||
|
||||
with TestClient(app):
|
||||
assert app.state.database is fake_database
|
||||
assert events == []
|
||||
|
||||
assert events == ["closed"]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- [x] 2.1 Define the cloud repository contract for hosts, device snapshots, plugins, tasks, attempts, leases, reservations, and transactional assignment operations.
|
||||
- [x] 2.2 Implement SQLAlchemy models and a repository adapter that preserves existing `CloudStore` observable behavior.
|
||||
- [ ] 2.3 Add PostgreSQL and SQLite database URL support with engine/session lifecycle owned by the cloud application.
|
||||
- [x] 2.3 Add PostgreSQL and SQLite database URL support with engine/session lifecycle owned by the cloud application.
|
||||
- [ ] 2.4 Add Alembic configuration and a baseline migration that preserves existing host, device, task, and plugin data while adding lease/attempt/result fields.
|
||||
- [ ] 2.5 Add forward/downgrade migration tests and schema-version readiness checks.
|
||||
- [ ] 2.6 Run repository contract tests against SQLite and PostgreSQL, including rollback and process-restart cases.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Engine, create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from cloud.sql_repository import SQLAlchemyCloudRepository
|
||||
|
||||
|
||||
def normalize_database_url(database_url: str) -> str:
|
||||
if database_url.startswith("postgresql://"):
|
||||
return "postgresql+psycopg://" + database_url.removeprefix("postgresql://")
|
||||
return database_url
|
||||
|
||||
|
||||
def create_database_engine(database_url: str) -> Engine:
|
||||
normalized_url = normalize_database_url(database_url)
|
||||
options: dict[str, object] = {"pool_pre_ping": True}
|
||||
if normalized_url.startswith("sqlite:///"):
|
||||
options["connect_args"] = {"check_same_thread": False}
|
||||
if normalized_url == "sqlite:///:memory:":
|
||||
options["poolclass"] = StaticPool
|
||||
return create_engine(normalized_url, **options)
|
||||
|
||||
|
||||
class CloudDatabase:
|
||||
"""Own the cloud database engine and repository lifecycle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database_url: str,
|
||||
*,
|
||||
create_schema: bool = True,
|
||||
) -> None:
|
||||
self.engine = create_database_engine(database_url)
|
||||
self.repository = SQLAlchemyCloudRepository(
|
||||
self.engine,
|
||||
create_schema=create_schema,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.repository.close()
|
||||
@@ -6,6 +6,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"device-agent-runtime==0.1.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cloud.database import CloudDatabase, create_database_engine, normalize_database_url
|
||||
|
||||
|
||||
def test_sqlite_database_owns_working_repository_lifecycle(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'cloud.sqlite3').as_posix()}")
|
||||
|
||||
database.repository.health_check()
|
||||
assert database.engine.dialect.name == "sqlite"
|
||||
|
||||
database.close()
|
||||
|
||||
|
||||
def test_in_memory_sqlite_uses_one_shared_pool() -> None:
|
||||
database = CloudDatabase("sqlite:///:memory:")
|
||||
try:
|
||||
database.repository.health_check()
|
||||
assert database.engine.pool.__class__.__name__ == "StaticPool"
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_postgresql_url_uses_psycopg_dialect_without_connecting() -> None:
|
||||
database = CloudDatabase(
|
||||
"postgresql://user:secret@localhost/cloud",
|
||||
create_schema=False,
|
||||
)
|
||||
try:
|
||||
assert database.engine.dialect.name == "postgresql"
|
||||
assert database.engine.dialect.driver == "psycopg"
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_normalize_database_url_preserves_explicit_dialects() -> None:
|
||||
assert (
|
||||
normalize_database_url("postgresql://db/cloud")
|
||||
== "postgresql+psycopg://db/cloud"
|
||||
)
|
||||
assert normalize_database_url("sqlite:///cloud.db") == "sqlite:///cloud.db"
|
||||
|
||||
|
||||
def test_sqlite_engine_allows_cross_thread_fastapi_usage() -> None:
|
||||
engine = create_database_engine("sqlite:///:memory:")
|
||||
try:
|
||||
assert engine.url.drivername == "sqlite"
|
||||
assert engine.pool.__class__.__name__ == "StaticPool"
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -487,12 +487,14 @@ version = "0.1.0"
|
||||
source = { editable = "packages/cloud-platform" }
|
||||
dependencies = [
|
||||
{ name = "device-agent-runtime" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "device-agent-runtime", editable = "." },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
]
|
||||
|
||||
@@ -1266,6 +1268,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-cpuinfo"
|
||||
version = "9.0.0"
|
||||
|
||||
Reference in New Issue
Block a user