feat(projects): automate project infrastructure provisioning
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m13s

This commit is contained in:
2026-09-11 10:57:51 +08:00
parent 10a7a66a41
commit 90b02057bc
38 changed files with 2345 additions and 189 deletions
+46
View File
@@ -0,0 +1,46 @@
from xml.etree import ElementTree
import httpx
from app.infra.geoserver import client as geoserver_client
def test_project_workspace_sets_30_day_client_cache(monkeypatch):
cache_updates: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if request.method == "GET" and path.endswith("/rest/workspaces/demo.json"):
return httpx.Response(404)
if request.method == "GET" and "/gwc/rest/layers/" in path:
return httpx.Response(
200,
content=b"<GeoServerLayer><name>demo</name><expireClients>0</expireClients></GeoServerLayer>",
)
if request.method == "PUT" and "/gwc/rest/layers/" in path:
root = ElementTree.fromstring(request.content)
cache_updates.append(root.findtext("expireClients"))
return httpx.Response(200)
return httpx.Response(201)
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_USERNAME", "admin")
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_PASSWORD", "secret")
monkeypatch.setattr(
geoserver_client.settings,
"GEOSERVER_CLIENT_CACHE_SECONDS",
2_592_000,
)
http_client = httpx.Client(
base_url="http://geoserver.example/geoserver",
transport=httpx.MockTransport(handler),
)
client = geoserver_client.GeoServerAdminClient(client=http_client)
layers = client.create_project_workspace(
workspace="demo",
database_name="demo",
map_bbox=(1.0, 2.0, 3.0, 4.0),
)
assert layers == geoserver_client.PROJECT_LAYER_NAMES
assert cache_updates == ["2592000"] * len(layers)
@@ -5,6 +5,7 @@ from uuid import uuid4
import pytest
from sqlalchemy.dialects import postgresql
from app.infra.db.metadb.repositories import metadata_repository
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
@@ -49,3 +50,38 @@ async def test_list_projects_for_user_only_queries_active_projects():
assert "user_project_membership.user_id" in sql
assert "projects.status = 'active'" in sql
assert [item.code for item in projects] == ["active-project"]
@pytest.mark.anyio
async def test_create_provisioned_project_commits_project_routes_and_membership_once(
monkeypatch,
):
added = []
session = SimpleNamespace(
add=added.append,
commit=AsyncMock(),
refresh=AsyncMock(),
)
monkeypatch.setattr(
metadata_repository,
"_encrypt_database_secret",
lambda value: f"encrypted::{value}",
)
user_id = uuid4()
project = await MetadataRepository(session).create_provisioned_project(
name="Demo",
code="demo",
description=None,
gs_workspace="demo",
map_extent={"bbox": [1, 2, 3, 4], "zoom": 14},
creator_user_id=user_id,
business_dsn="postgresql://business/demo",
timescale_dsn="postgresql://timescale/demo",
)
assert project.status == "active"
assert [record.db_role for record in added[1:3]] == ["biz_data", "iot_data"]
assert added[3].user_id == user_id
session.commit.assert_awaited_once()
session.refresh.assert_awaited_once_with(project)
+31
View File
@@ -0,0 +1,31 @@
from app.native.wndb.core.model_replace import _copy_out_statement
def test_copy_out_statement_relabels_project_geometry_to_target_srid() -> None:
statement = _copy_out_statement(
"gis",
"labels",
["id", "label", "geom"],
source_geometry_srids={"geom": 900914},
target_geometry_srids={"geom": 990001},
)
assert statement.as_string(None) == (
'copy (select "id", "label", '
'st_setsrid("geom", 990001) as "geom" '
'from "gis"."labels") to stdout'
)
def test_copy_out_statement_keeps_direct_copy_when_srid_matches() -> None:
statement = _copy_out_statement(
"gis",
"node_geometries",
["node_id", "geom"],
source_geometry_srids={"geom": 900914},
target_geometry_srids={"geom": 900914},
)
assert statement.as_string(None) == (
'copy "gis"."node_geometries" ("node_id", "geom") to stdout'
)
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import pytest
from app.services import project_inp
def test_temporary_project_inp_exports_unique_file_and_removes_it(
monkeypatch, tmp_path
) -> None:
exported_paths: list[Path] = []
def fake_dump_inp(project_code: str, path: str, version: str) -> None:
assert project_code == "demo"
assert version == "2"
exported_path = Path(path)
exported_path.write_text("[TITLE]\ndemo", encoding="utf-8")
exported_paths.append(exported_path)
monkeypatch.setattr(project_inp, "PROJECT_INP_DIRECTORY", tmp_path)
monkeypatch.setattr(project_inp, "dump_inp", fake_dump_inp)
with project_inp.temporary_project_inp(
"demo", purpose="sensor-placement"
) as first_path:
assert first_path.read_text(encoding="utf-8") == "[TITLE]\ndemo"
with project_inp.temporary_project_inp(
"demo", purpose="sensor-placement"
) as second_path:
assert second_path.exists()
assert first_path != second_path
assert exported_paths == [first_path, second_path]
assert all(not path.exists() for path in exported_paths)
def test_temporary_project_inp_removes_empty_export(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(project_inp, "PROJECT_INP_DIRECTORY", tmp_path)
monkeypatch.setattr(project_inp, "dump_inp", lambda *_args: None)
with pytest.raises(ValueError, match="INP 导出失败"):
with project_inp.temporary_project_inp("missing", purpose="burst"):
pass
assert list(tmp_path.iterdir()) == []
+158
View File
@@ -0,0 +1,158 @@
from pathlib import Path
import pytest
from app.services import project_provisioning
class _FakeGeoServer:
calls: list[tuple] = []
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def check_ready(self):
self.calls.append(("geoserver_ready",))
def workspace_exists(self, workspace):
self.calls.append(("workspace_exists", workspace))
return False
def create_project_workspace(self, **kwargs):
self.calls.append(("create_workspace", kwargs))
return ("junctions", "pipes")
def delete_workspace(self, workspace):
self.calls.append(("delete_workspace", workspace))
def _arrange_preflight(monkeypatch, calls):
schema_template = project_provisioning.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME
monkeypatch.setattr(
project_provisioning,
"have_project",
lambda name: name == schema_template,
)
monkeypatch.setattr(
project_provisioning,
"require_timescale_schema_template",
lambda: calls.append(("require_timescale_template",)),
)
monkeypatch.setattr(
project_provisioning,
"ensure_replication_worker_capacity",
lambda: calls.append(("replication_capacity",)),
)
monkeypatch.setattr(
project_provisioning,
"timescale_database_exists",
lambda _name: False,
)
monkeypatch.setattr(project_provisioning, "GeoServerAdminClient", _FakeGeoServer)
def test_provision_project_creates_resources_in_dependency_order(monkeypatch):
calls: list[tuple] = []
_FakeGeoServer.calls = calls
_arrange_preflight(monkeypatch, calls)
monkeypatch.setattr(
project_provisioning,
"create_project",
lambda name: calls.append(("create_business", name)),
)
monkeypatch.setattr(
project_provisioning,
"network_update",
lambda path, name: calls.append(("import_model", Path(path).name, name)),
)
monkeypatch.setattr(
project_provisioning,
"get_project_map_bbox",
lambda name: (1.0, 2.0, 3.0, 4.0),
)
monkeypatch.setattr(
project_provisioning,
"create_project_model_template",
lambda name: calls.append(("create_model_template", name)) or "demo_template",
)
monkeypatch.setattr(
project_provisioning,
"create_timescale_database",
lambda name: calls.append(("create_timescale", name)),
)
result = project_provisioning.provision_project_infrastructure(
code="demo",
workspace="demo",
inp_path="model.inp",
)
ordered_actions = [call[0] for call in calls]
assert ordered_actions.index("create_business") < ordered_actions.index("import_model")
assert ordered_actions.index("import_model") < ordered_actions.index("create_model_template")
assert ordered_actions.index("create_model_template") < ordered_actions.index("create_timescale")
assert ordered_actions.index("create_timescale") < ordered_actions.index("create_workspace")
assert result.model_template == "demo_template"
assert result.map_bbox == (1.0, 2.0, 3.0, 4.0)
def test_provision_project_rolls_back_created_resources_in_reverse_order(monkeypatch):
calls: list[tuple] = []
_FakeGeoServer.calls = calls
_arrange_preflight(monkeypatch, calls)
monkeypatch.setattr(
project_provisioning,
"create_project",
lambda name: calls.append(("create_business", name)),
)
monkeypatch.setattr(project_provisioning, "network_update", lambda *_args: None)
monkeypatch.setattr(
project_provisioning,
"get_project_map_bbox",
lambda _name: (1.0, 2.0, 3.0, 4.0),
)
monkeypatch.setattr(
project_provisioning,
"create_project_model_template",
lambda name: calls.append(("create_model_template", name)) or "demo_template",
)
monkeypatch.setattr(
project_provisioning,
"create_timescale_database",
lambda _name: (_ for _ in ()).throw(RuntimeError("timescale unavailable")),
)
monkeypatch.setattr(
project_provisioning,
"delete_project_model_template",
lambda name: calls.append(("delete_model_template", name)),
)
monkeypatch.setattr(
project_provisioning,
"delete_project",
lambda name: calls.append(("delete_business", name)),
)
with pytest.raises(project_provisioning.ProjectProvisioningError) as exc_info:
project_provisioning.provision_project_infrastructure(
code="demo",
workspace="demo",
inp_path="model.inp",
)
assert exc_info.value.stage == "timescaledb"
assert [call[0] for call in calls[-2:]] == [
"delete_model_template",
"delete_business",
]
@pytest.mark.parametrize(
"code",
["Demo", "1demo", "demo-project", "demo_template", "postgres"],
)
def test_validate_project_code_rejects_unsafe_or_reserved_names(code):
with pytest.raises(ValueError):
project_provisioning.validate_project_code(code)
+22 -13
View File
@@ -7,6 +7,7 @@ from app.infra.db.project_routing import (
get_active_project_routing,
get_project_database_name,
get_project_pgconn_string,
get_schema_template_database_name,
get_project_template_database_name,
get_project_timescale_pgconn_string,
)
@@ -37,12 +38,12 @@ def test_project_database_uses_exact_routing_dsn_for_project_code() -> None:
)
def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name(
def test_business_template_follows_physical_project_and_timescale_uses_routing(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
"tjwater_v2_template",
"app.infra.db.project_routing.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME",
"tjwater_v2_schema_template",
)
with activate_project_routing(_routing()):
template_name = get_project_template_database_name("project_a")
@@ -54,7 +55,7 @@ def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name(
assert business == {
"user": "biz_user",
"password": "biz_password",
"dbname": "tjwater_v2_template",
"dbname": "biz_database_template",
"host": "biz.example",
"port": "5432",
"sslmode": "require",
@@ -71,24 +72,32 @@ def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name(
def test_project_code_resolves_to_physical_business_database(monkeypatch) -> None:
monkeypatch.setattr(
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
"tjwater_v2_template",
"app.infra.db.project_routing.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME",
"tjwater_v2_schema_template",
)
with activate_project_routing(_routing()):
assert get_project_database_name("project_a") == "biz_database"
assert get_project_database_name("temporary_run") == "temporary_run"
assert get_project_template_database_name("project_a") == "tjwater_v2_template"
assert (
get_project_template_database_name("project_a")
== "biz_database_template"
)
def test_template_falls_back_to_config_outside_project_routing(monkeypatch) -> None:
def test_project_template_uses_database_name_outside_project_routing() -> None:
assert (
get_project_template_database_name("physical_project")
== "physical_project_template"
)
def test_schema_template_comes_from_config(monkeypatch) -> None:
monkeypatch.setattr(
"app.infra.db.project_routing.settings.WNDB_TEMPLATE_DB_NAME",
"tjwater_v2_template",
"app.infra.db.project_routing.settings.WNDB_SCHEMA_TEMPLATE_DB_NAME",
"tjwater_v2_schema_template",
)
assert get_project_template_database_name("ignored-project-code") == (
"tjwater_v2_template"
)
assert get_schema_template_database_name() == "tjwater_v2_schema_template"
def test_project_routing_is_nested_and_request_local() -> None:
+10 -2
View File
@@ -39,7 +39,9 @@ def test_read_inp_refreshes_after_committed_replace_when_cleanup_fails(
monkeypatch.setattr(
importer,
"copy_project",
lambda _source, _target: events.append("copy"),
lambda source, _target, **kwargs: events.append(
f"copy:{source}:{kwargs.get('allow_template_source')}"
),
)
monkeypatch.setattr(importer, "project_transaction", fake_transaction)
monkeypatch.setattr(
@@ -63,4 +65,10 @@ def test_read_inp_refreshes_after_committed_replace_when_cleanup_fails(
)
assert importer.read_inp("project_a", "model.inp") is True
assert events == ["copy", "parse", "replace", "cleanup-failed", "refresh"]
assert events == [
"copy:tjwater_v2_schema_template:True",
"parse",
"replace",
"cleanup-failed",
"refresh",
]
+38
View File
@@ -0,0 +1,38 @@
from app.native.wndb.model.options_v3 import _inp_in_option_v3, inp_in_option_v3
def _operation_by_type(section: list[str], option_type: str) -> dict[str, str]:
result = _inp_in_option_v3(section)
return next(operation for operation in result.operations if operation["type"] == option_type)
def test_v3_import_preserves_legacy_options_from_standard_inp() -> None:
section = [
"Units LPS",
"Pattern PAT_BASE",
"Quality None mg/L",
"Trials 80",
"Unbalanced Continue 10",
"CHECKFREQ 2",
]
legacy = _operation_by_type(section, "option")
v3 = _operation_by_type(section, "option_v3")
assert legacy["PATTERN"] == "PAT_BASE"
assert legacy["QUALITY"] == "None mg/L"
assert legacy["TRIALS"] == "80"
assert legacy["UNBALANCED"] == "CONTINUE 10"
assert legacy["CHECKFREQ"] == "2"
assert v3["DEMAND_PATTERN"] == "PAT_BASE"
assert v3["QUALITY_MODEL"] == "NONE"
statement = inp_in_option_v3(section)
assert "engine_version = 'legacy' and key = 'PATTERN'" in statement
assert "engine_version = 'v3' and key = 'DEMAND_PATTERN'" in statement
def test_v3_import_ignores_empty_option_lines() -> None:
legacy = _operation_by_type(["", " ", "Pattern PAT_BASE"], "option")
assert legacy["PATTERN"] == "PAT_BASE"
+92 -23
View File
@@ -43,6 +43,15 @@ class _FakeConnection:
return self._cursor
class _SequenceCursor(_FakeCursor):
def __init__(self, rows: list[dict]) -> None:
super().__init__()
self._fetch_rows = iter(rows)
def fetchone(self):
return next(self._fetch_rows)
def _admin_connection(cursor: _FakeCursor):
@contextmanager
def connection():
@@ -51,14 +60,22 @@ def _admin_connection(cursor: _FakeCursor):
return connection
def _project_connection(cursor: _FakeCursor):
@contextmanager
def connection(_name):
yield _FakeConnection(cursor)
return connection
@pytest.mark.parametrize(
"name",
[
"postgres",
"project",
"system_hub",
"SYSTEM_HUB",
"tjwater_v2_template",
"tjwater_v2_schema_template",
"another_template",
],
)
@@ -109,12 +126,12 @@ def test_create_project_allows_the_protected_template_as_source(monkeypatch) ->
projects.create_project("project_a")
assert closed == ["tjwater_v2_template", "project_a"]
assert any(call[1] == ("tjwater_v2_template",) for call in cursor.calls)
assert closed == ["tjwater_v2_schema_template", "project_a"]
assert any(call[1] == ("tjwater_v2_schema_template",) for call in cursor.calls)
assert any(
isinstance(call[0], str)
and call[0].startswith("select pg_terminate_backend")
and call[1] == ("tjwater_v2_template",)
and call[1] == ("tjwater_v2_schema_template",)
for call in cursor.calls
)
assert any("create database" in str(call[0]).lower() for call in cursor.calls)
@@ -127,8 +144,7 @@ def test_list_project_excludes_metadata_database(monkeypatch) -> None:
assert projects.list_project() == ["project_a"]
excluded = cursor.calls[0][1][0]
assert "system_hub" in excluded
assert "project" in excluded
assert "tjwater_v2_template" in excluded
assert "tjwater_v2_schema_template" in excluded
def test_delete_project_uses_routed_physical_database_name(monkeypatch) -> None:
@@ -177,6 +193,63 @@ def test_temporary_database_capacity_rejects_creation_at_limit(
assert any("pg_advisory_unlock" in str(statement) for statement, _ in cursor.calls)
def test_project_model_template_requires_ready_active_subscription(monkeypatch) -> None:
cursor = _SequenceCursor(
[
{
"subscriptions": 1,
"enabled_subscriptions": 1,
"active_workers": 1,
},
{"relations": 32, "pending_relations": 0},
]
)
closed: list[str] = []
monkeypatch.setattr(projects, "project_connection", _project_connection(cursor))
monkeypatch.setattr(projects, "close_project_pool", closed.append)
projects._ensure_project_model_template_ready("project_a_template")
assert closed == ["project_a_template"]
@pytest.mark.parametrize(
("status", "relations", "message"),
[
(
{
"subscriptions": 1,
"enabled_subscriptions": 1,
"active_workers": 0,
},
{"relations": 32, "pending_relations": 0},
"subscription is not active",
),
(
{
"subscriptions": 1,
"enabled_subscriptions": 1,
"active_workers": 1,
},
{"relations": 32, "pending_relations": 1},
"still synchronizing",
),
],
)
def test_project_model_template_rejects_unready_subscription(
monkeypatch, status, relations, message
) -> None:
cursor = _SequenceCursor([status, relations])
closed: list[str] = []
monkeypatch.setattr(projects, "project_connection", _project_connection(cursor))
monkeypatch.setattr(projects, "close_project_pool", closed.append)
with pytest.raises(RuntimeError, match=message):
projects._ensure_project_model_template_ready("project_a_template")
assert closed == ["project_a_template"]
def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None:
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(
@@ -187,7 +260,9 @@ def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None
monkeypatch.setattr(
projects,
"copy_project",
lambda source, target: calls.append(("copy", source, target)),
lambda source, target, **kwargs: calls.append(
("copy", source, target, str(kwargs.get("allow_template_source")))
),
)
monkeypatch.setattr(projects, "have_project", lambda name: True)
monkeypatch.setattr(
@@ -195,26 +270,13 @@ def test_temporary_project_database_cleans_up_after_failure(monkeypatch) -> None
"delete_project",
lambda name: calls.append(("delete", name)),
)
monkeypatch.setattr(
"app.native.wndb.core.model_replace.replace_project_model",
lambda target, source, *, copy_source_scada: calls.append(
("clone", target, source, str(copy_source_scada))
),
)
monkeypatch.setattr(
"app.native.wndb.core.database.refresh_materialized_views_after_commit",
lambda name: calls.append(("refresh", name)),
)
with pytest.raises(RuntimeError, match="analysis failed"):
with projects.temporary_project_database("project_a", "age") as name:
assert name == "isolated_run"
raise RuntimeError("analysis failed")
assert calls == [
("copy", "tjwater_v2_template", "isolated_run"),
("clone", "isolated_run", "project_a", "True"),
("refresh", "isolated_run"),
("copy", "project_a_template", "isolated_run", "True"),
("delete", "isolated_run"),
]
@@ -229,7 +291,9 @@ def test_temporary_template_database_does_not_clone_a_project(monkeypatch) -> No
monkeypatch.setattr(
projects,
"copy_project",
lambda source, target: calls.append(("copy", source, target)),
lambda source, target, **kwargs: calls.append(
("copy", source, target, str(kwargs.get("allow_template_source")))
),
)
monkeypatch.setattr(projects, "have_project", lambda name: True)
monkeypatch.setattr(
@@ -242,7 +306,12 @@ def test_temporary_template_database_does_not_clone_a_project(monkeypatch) -> No
assert name == "empty_conversion"
assert calls == [
("copy", "tjwater_v2_template", "empty_conversion"),
(
"copy",
"tjwater_v2_schema_template",
"empty_conversion",
"True",
),
("delete", "empty_conversion"),
]