From 64aa9b39bca2b27921360a7db9b912ef86dc71db Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sun, 12 Jul 2026 16:53:02 +0800 Subject: [PATCH] feat(cloud-store): support SQLite and PostgreSQL engines --- apps/cloud-api/cloud_api/app.py | 37 +++++++++++++- apps/cloud-api/tests/test_app.py | 23 +++++++++ .../cloud-control-plane-integration/tasks.md | 2 +- packages/cloud-platform/cloud/database.py | 41 +++++++++++++++ packages/cloud-platform/pyproject.toml | 1 + tests/test_cloud_database.py | 50 +++++++++++++++++++ uv.lock | 37 ++++++++++++++ 7 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 packages/cloud-platform/cloud/database.py create mode 100644 tests/test_cloud_database.py diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index bc24738..0e36c7d 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -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", + ) diff --git a/apps/cloud-api/tests/test_app.py b/apps/cloud-api/tests/test_app.py index 0f8f3de..5631f1e 100644 --- a/apps/cloud-api/tests/test_app.py +++ b/apps/cloud-api/tests/test_app.py @@ -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"] diff --git a/openspec/changes/cloud-control-plane-integration/tasks.md b/openspec/changes/cloud-control-plane-integration/tasks.md index c7b10ad..a38e30a 100644 --- a/openspec/changes/cloud-control-plane-integration/tasks.md +++ b/openspec/changes/cloud-control-plane-integration/tasks.md @@ -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. diff --git a/packages/cloud-platform/cloud/database.py b/packages/cloud-platform/cloud/database.py new file mode 100644 index 0000000..57daab7 --- /dev/null +++ b/packages/cloud-platform/cloud/database.py @@ -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() diff --git a/packages/cloud-platform/pyproject.toml b/packages/cloud-platform/pyproject.toml index db73c9a..e4f0ef4 100644 --- a/packages/cloud-platform/pyproject.toml +++ b/packages/cloud-platform/pyproject.toml @@ -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", ] diff --git a/tests/test_cloud_database.py b/tests/test_cloud_database.py new file mode 100644 index 0000000..38fea8c --- /dev/null +++ b/tests/test_cloud_database.py @@ -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() diff --git a/uv.lock b/uv.lock index 615685e..ac66bf0 100644 --- a/uv.lock +++ b/uv.lock @@ -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"