Skip to content

Commit 5c8ba74

Browse files
jochem25claude
andcommitted
feat: hybrid Nextcloud I/O with multi-tenant support
Migrate cloud storage from pure WebDAV to hybrid model: - Reads: direct filesystem from NC volume mount (fast, no network overhead) - Writes: still via WebDAV (keeps Nextcloud metadata in sync) - Multi-tenant: tenants.json config with per-tenant NC instances - Fallback: WebDAV reads when volume mount unavailable New modules: - server/tenant_config.py: multi-tenant config loader - server/volume_reader.py: direct volume mount filesystem reader - server/routers/cloud.py: refactored cloud API router (was inline in main.py) - config/tenants.json: 3BM tenant configuration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a2d3b11 commit 5c8ba74

7 files changed

Lines changed: 710 additions & 200 deletions

File tree

config/tenants.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"tenants": {
3+
"3bm": {
4+
"name": "3BM Cooperatie",
5+
"nextcloud_url": "http://nc-3bm:80",
6+
"nextcloud_domain": "cloud-3bm.open-aec.com",
7+
"service_user": "openaec-service",
8+
"service_pass_env": "NC_SERVICE_PASS_3BM",
9+
"group_folder_id": 1
10+
}
11+
}
12+
}

docker-compose.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,20 @@ services:
2424
- CORS_ORIGINS=*
2525
- DATABASE_URL=postgresql+asyncpg://bimvalidator:${POSTGRES_PASSWORD:-changeme}@db:5432/bimvalidator
2626
- PROJECT_FILES_DIR=/data/projects
27-
# Nextcloud cloud storage (opt-in — leave empty to disable)
28-
- NEXTCLOUD_URL=
29-
- NEXTCLOUD_SERVICE_USER=
30-
- NEXTCLOUD_SERVICE_PASS=
27+
# Multi-tenant cloud storage (Nextcloud hybrid I/O)
28+
- TENANTS_CONFIG=/etc/openaec/tenants.json
29+
- NC_SERVICE_PASS_3BM=${NC_SERVICE_PASS_3BM:-}
3130
volumes:
3231
# Persist uploaded files across restarts
3332
- bim-uploads:/tmp/ifc_uploads
3433
- bim-processed:/tmp/ifc_processed
3534
- bim-jobs:/tmp/ids_validation_jobs
3635
# Persistent project file storage
3736
- bim-projects:/data/projects
37+
# Tenant config
38+
- ./config/tenants.json:/etc/openaec/tenants.json:ro
39+
# Nextcloud data volume (read-only, direct filesystem access)
40+
- tenant-3bm_nc-3bm-data:/nc-data-3bm:ro
3841
networks:
3942
- default
4043
- openaec_platform
@@ -49,6 +52,8 @@ volumes:
4952
bim-processed:
5053
bim-jobs:
5154
bim-projects:
55+
tenant-3bm_nc-3bm-data:
56+
external: true
5257

5358
networks:
5459
openaec_platform:

server/main.py

Lines changed: 5 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
ValidationStatus,
4141
)
4242
from server.project_manager import ProjectManager
43+
from server.routers.cloud import router as cloud_router
4344
from server.routers.projects import router as projects_router
4445
from ifc_validator.standards.resolver import get_bundled_ids
4546

@@ -79,6 +80,7 @@ def format(self, record: logging.LogRecord) -> str:
7980

8081
# Include persistent project router (PostgreSQL-backed)
8182
app.include_router(projects_router)
83+
app.include_router(cloud_router)
8284

8385

8486
@app.on_event("startup")
@@ -89,8 +91,10 @@ async def startup():
8991

9092
@app.on_event("shutdown")
9193
async def shutdown():
92-
"""Clean up database connections on shutdown."""
94+
"""Clean up database connections and HTTP clients on shutdown."""
9395
await close_db()
96+
from server.nextcloud_client import close_all_clients
97+
await close_all_clients()
9498

9599

96100
# Configure CORS for browser access
@@ -1040,200 +1044,6 @@ async def get_element_properties(model_id: str, global_id: str):
10401044
return props
10411045

10421046

1043-
# ==========================================================================
1044-
# Cloud storage (Nextcloud) — opt-in via environment variables
1045-
# ==========================================================================
1046-
1047-
NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "")
1048-
NEXTCLOUD_SERVICE_USER = os.environ.get("NEXTCLOUD_SERVICE_USER", "")
1049-
NEXTCLOUD_SERVICE_PASS = os.environ.get("NEXTCLOUD_SERVICE_PASS", "")
1050-
CLOUD_ENABLED = bool(NEXTCLOUD_URL and NEXTCLOUD_SERVICE_USER and NEXTCLOUD_SERVICE_PASS)
1051-
1052-
_nextcloud_client = None
1053-
1054-
1055-
def get_nextcloud_client():
1056-
"""Get or create the singleton NextcloudClient.
1057-
1058-
Returns the client if cloud storage is configured, otherwise raises 503.
1059-
"""
1060-
global _nextcloud_client
1061-
if not CLOUD_ENABLED:
1062-
raise HTTPException(
1063-
status_code=503,
1064-
detail="Cloud storage is not configured",
1065-
)
1066-
if _nextcloud_client is None:
1067-
from server.nextcloud_client import NextcloudClient
1068-
1069-
_nextcloud_client = NextcloudClient(
1070-
base_url=NEXTCLOUD_URL,
1071-
username=NEXTCLOUD_SERVICE_USER,
1072-
password=NEXTCLOUD_SERVICE_PASS,
1073-
)
1074-
return _nextcloud_client
1075-
1076-
1077-
@app.get("/api/cloud/status")
1078-
async def cloud_status():
1079-
"""Check if cloud storage is enabled and reachable."""
1080-
from server.models.cloud import CloudStatusResponse
1081-
1082-
if not CLOUD_ENABLED:
1083-
return CloudStatusResponse(enabled=False, connected=False)
1084-
1085-
client = get_nextcloud_client()
1086-
try:
1087-
connected = await client.test_connection()
1088-
except Exception:
1089-
connected = False
1090-
1091-
return CloudStatusResponse(enabled=True, connected=connected)
1092-
1093-
1094-
@app.get("/api/cloud/projects")
1095-
async def cloud_list_projects():
1096-
"""List available project folders in Nextcloud."""
1097-
from server.models.cloud import CloudProjectItem, CloudProjectsResponse
1098-
1099-
client = get_nextcloud_client()
1100-
try:
1101-
items = await client.list_projects()
1102-
except Exception as exc:
1103-
raise HTTPException(status_code=502, detail=str(exc)) from exc
1104-
1105-
return CloudProjectsResponse(
1106-
projects=[
1107-
CloudProjectItem(name=item.name, last_modified=item.last_modified)
1108-
for item in items
1109-
]
1110-
)
1111-
1112-
1113-
@app.get("/api/cloud/projects/{project}/files")
1114-
async def cloud_list_files(project: str):
1115-
"""List BCF files in a project's tool subdirectory."""
1116-
from server.models.cloud import CloudFileItem, CloudFilesResponse
1117-
1118-
client = get_nextcloud_client()
1119-
try:
1120-
items = await client.list_files(project)
1121-
except Exception as exc:
1122-
from server.nextcloud_client import NextcloudError
1123-
1124-
if isinstance(exc, NextcloudError) and exc.status_code:
1125-
raise HTTPException(
1126-
status_code=exc.status_code, detail=str(exc)
1127-
) from exc
1128-
raise HTTPException(status_code=502, detail=str(exc)) from exc
1129-
1130-
return CloudFilesResponse(
1131-
project=project,
1132-
files=[
1133-
CloudFileItem(
1134-
name=item.name,
1135-
size=item.content_length,
1136-
last_modified=item.last_modified,
1137-
)
1138-
for item in items
1139-
],
1140-
)
1141-
1142-
1143-
@app.get("/api/cloud/projects/{project}/files/{filename}")
1144-
async def cloud_download_file(project: str, filename: str):
1145-
"""Download a file from a project's tool subdirectory."""
1146-
client = get_nextcloud_client()
1147-
try:
1148-
content = await client.download_file(project, filename)
1149-
except Exception as exc:
1150-
from server.nextcloud_client import NextcloudError
1151-
1152-
if isinstance(exc, NextcloudError) and exc.status_code:
1153-
raise HTTPException(
1154-
status_code=exc.status_code, detail=str(exc)
1155-
) from exc
1156-
raise HTTPException(status_code=502, detail=str(exc)) from exc
1157-
1158-
return Response(
1159-
content=content,
1160-
media_type="application/octet-stream",
1161-
headers={
1162-
"Content-Disposition": f'attachment; filename="{filename}"',
1163-
},
1164-
)
1165-
1166-
1167-
@app.put("/api/cloud/projects/{project}/files/{filename}")
1168-
async def cloud_upload_file(
1169-
project: str,
1170-
filename: str,
1171-
file: UploadFile = File(...),
1172-
):
1173-
"""Upload a file to a project's tool subdirectory."""
1174-
from server.models.cloud import CloudUploadResponse
1175-
1176-
client = get_nextcloud_client()
1177-
content = await file.read()
1178-
try:
1179-
await client.upload_file(project, filename, content)
1180-
except Exception as exc:
1181-
from server.nextcloud_client import NextcloudError
1182-
1183-
if isinstance(exc, NextcloudError) and exc.status_code:
1184-
raise HTTPException(
1185-
status_code=exc.status_code, detail=str(exc)
1186-
) from exc
1187-
raise HTTPException(status_code=502, detail=str(exc)) from exc
1188-
1189-
return CloudUploadResponse(success=True, project=project, filename=filename)
1190-
1191-
1192-
@app.delete("/api/cloud/projects/{project}/files/{filename}")
1193-
async def cloud_delete_file(project: str, filename: str):
1194-
"""Delete a file from a project's tool subdirectory."""
1195-
from server.models.cloud import CloudDeleteResponse
1196-
1197-
client = get_nextcloud_client()
1198-
try:
1199-
await client.delete_file(project, filename)
1200-
except Exception as exc:
1201-
from server.nextcloud_client import NextcloudError
1202-
1203-
if isinstance(exc, NextcloudError) and exc.status_code:
1204-
raise HTTPException(
1205-
status_code=exc.status_code, detail=str(exc)
1206-
) from exc
1207-
raise HTTPException(status_code=502, detail=str(exc)) from exc
1208-
1209-
return CloudDeleteResponse(success=True, project=project, filename=filename)
1210-
1211-
1212-
@app.post("/api/cloud/projects/{project}/save")
1213-
async def cloud_save_bcf(
1214-
project: str,
1215-
file: UploadFile = File(...),
1216-
filename: str = Form("validation.bcf"),
1217-
):
1218-
"""Convenience endpoint: save a BCF file to a project's cloud folder."""
1219-
from server.models.cloud import CloudUploadResponse
1220-
1221-
client = get_nextcloud_client()
1222-
content = await file.read()
1223-
try:
1224-
await client.upload_file(project, filename, content)
1225-
except Exception as exc:
1226-
from server.nextcloud_client import NextcloudError
1227-
1228-
if isinstance(exc, NextcloudError) and exc.status_code:
1229-
raise HTTPException(
1230-
status_code=exc.status_code, detail=str(exc)
1231-
) from exc
1232-
raise HTTPException(status_code=502, detail=str(exc)) from exc
1233-
1234-
return CloudUploadResponse(success=True, project=project, filename=filename)
1235-
1236-
12371047
# ==========================================================================
12381048
# Static file serving — serve built frontend in production
12391049
# ==========================================================================

server/nextcloud_client.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
44
Provides async file operations (list, upload, download, delete) against
55
a Nextcloud instance using WebDAV. Authentication uses Basic auth with
6-
a service account.
6+
a service account. Multi-tenant: one client instance per tenant.
77
88
Usage:
99
client = NextcloudClient(
@@ -12,14 +12,21 @@
1212
password="secret",
1313
)
1414
projects = await client.list_projects()
15+
16+
# Or from tenant config:
17+
client = NextcloudClient.from_tenant(tenant_config)
1518
"""
1619

20+
from __future__ import annotations
21+
1722
import xml.etree.ElementTree as ET
1823
from dataclasses import dataclass
1924
from urllib.parse import quote, unquote
2025

2126
import httpx
2227

28+
from server.tenant_config import TenantConfig
29+
2330
DAV_NS = {"d": "DAV:"}
2431
TOOL_SLUG = "bim-validator"
2532
PROJECTS_ROOT = "Projects"
@@ -68,6 +75,15 @@ def __init__(
6875
timeout=timeout,
6976
)
7077

78+
@classmethod
79+
def from_tenant(cls, tenant: TenantConfig) -> NextcloudClient:
80+
"""Create a client from a TenantConfig."""
81+
return cls(
82+
base_url=tenant.nextcloud_url,
83+
username=tenant.service_user,
84+
password=tenant.service_pass,
85+
)
86+
7187
async def close(self) -> None:
7288
"""Close the underlying HTTP client."""
7389
await self._client.aclose()
@@ -344,3 +360,22 @@ def _parse_multistatus(self, xml_bytes: bytes, base_path: str) -> list[DavItem]:
344360
)
345361

346362
return items
363+
364+
365+
# ── Multi-tenant client registry ───────────────────────────────
366+
367+
_clients: dict[str, NextcloudClient] = {}
368+
369+
370+
def get_nc_client(tenant: TenantConfig) -> NextcloudClient:
371+
"""Get or create a NextcloudClient for the given tenant."""
372+
if tenant.slug not in _clients:
373+
_clients[tenant.slug] = NextcloudClient.from_tenant(tenant)
374+
return _clients[tenant.slug]
375+
376+
377+
async def close_all_clients() -> None:
378+
"""Close all cached clients. Call on app shutdown."""
379+
for client in _clients.values():
380+
await client.close()
381+
_clients.clear()

0 commit comments

Comments
 (0)