feat(projects): automate project infrastructure provisioning
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.core.config import settings
|
||||
from app.infra.db.timescaledb.lifecycle import (
|
||||
create_timescale_database,
|
||||
delete_timescale_database,
|
||||
require_timescale_schema_template,
|
||||
timescale_database_exists,
|
||||
)
|
||||
from app.infra.db.postgresql.project_spatial import get_project_map_bbox
|
||||
from app.infra.geoserver.client import GeoServerAdminClient
|
||||
from app.native.wndb.core.project_templates import (
|
||||
create_project_model_template,
|
||||
delete_project_model_template,
|
||||
ensure_replication_worker_capacity,
|
||||
)
|
||||
from app.native.wndb.core.projects import create_project, delete_project, have_project
|
||||
from app.services.network_import import network_update
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_PROJECT_CODE = re.compile(r"^[a-z][a-z0-9_]{0,49}$")
|
||||
|
||||
|
||||
class ProjectProvisioningError(RuntimeError):
|
||||
def __init__(self, stage: str, cause: Exception, cleanup_errors: list[str]) -> None:
|
||||
self.stage = stage
|
||||
self.cause = cause
|
||||
self.cleanup_errors = cleanup_errors
|
||||
cleanup_suffix = (
|
||||
f"; cleanup failures: {', '.join(cleanup_errors)}"
|
||||
if cleanup_errors
|
||||
else ""
|
||||
)
|
||||
super().__init__(f"Project provisioning failed at {stage}: {cause}{cleanup_suffix}")
|
||||
|
||||
|
||||
def validate_project_code(code: str) -> str:
|
||||
if not _PROJECT_CODE.fullmatch(code):
|
||||
raise ValueError(
|
||||
"Project code must start with a lowercase letter and contain only "
|
||||
"lowercase letters, digits, and underscores"
|
||||
)
|
||||
if code.endswith("_template"):
|
||||
raise ValueError("Project code must not end with '_template'")
|
||||
if code in {
|
||||
"postgres",
|
||||
"template0",
|
||||
"template1",
|
||||
settings.METADATA_DB_NAME.casefold(),
|
||||
}:
|
||||
raise ValueError(f"Project code {code!r} is reserved")
|
||||
return code
|
||||
|
||||
|
||||
def _database_url(*, timescale: bool, database_name: str) -> str:
|
||||
if timescale:
|
||||
host = settings.TIMESCALEDB_DB_HOST
|
||||
port = settings.TIMESCALEDB_DB_PORT
|
||||
user = settings.TIMESCALEDB_DB_USER
|
||||
password = settings.TIMESCALEDB_DB_PASSWORD
|
||||
else:
|
||||
host = settings.DB_HOST
|
||||
port = settings.DB_PORT
|
||||
user = settings.DB_USER
|
||||
password = settings.DB_PASSWORD
|
||||
return (
|
||||
f"postgresql://{quote(user, safe='')}:{quote(password, safe='')}"
|
||||
f"@{host}:{port}/{database_name}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProvisionedProjectInfrastructure:
|
||||
code: str
|
||||
workspace: str
|
||||
model_template: str
|
||||
map_bbox: tuple[float, float, float, float]
|
||||
layers: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def business_dsn(self) -> str:
|
||||
return _database_url(timescale=False, database_name=self.code)
|
||||
|
||||
@property
|
||||
def timescale_dsn(self) -> str:
|
||||
return _database_url(timescale=True, database_name=self.code)
|
||||
|
||||
def cleanup(self) -> list[str]:
|
||||
errors: list[str] = []
|
||||
cleanup_steps = (
|
||||
("geoserver", self._delete_geoserver),
|
||||
("timescaledb", lambda: delete_timescale_database(self.code)),
|
||||
("model_template", lambda: delete_project_model_template(self.code)),
|
||||
("business_database", lambda: delete_project(self.code)),
|
||||
)
|
||||
for name, cleanup in cleanup_steps:
|
||||
try:
|
||||
cleanup()
|
||||
except Exception as exc: # preserve every cleanup attempt
|
||||
logger.exception("Project provisioning cleanup failed at %s", name)
|
||||
errors.append(name)
|
||||
return errors
|
||||
|
||||
def _delete_geoserver(self) -> None:
|
||||
with GeoServerAdminClient() as geoserver:
|
||||
geoserver.delete_workspace(self.workspace)
|
||||
|
||||
|
||||
def _preflight(code: str, workspace: str, geoserver: GeoServerAdminClient) -> None:
|
||||
validate_project_code(code)
|
||||
if not _PROJECT_CODE.fullmatch(workspace):
|
||||
raise ValueError(
|
||||
"GeoServer workspace must start with a lowercase letter and contain "
|
||||
"only lowercase letters, digits, and underscores"
|
||||
)
|
||||
if have_project(settings.WNDB_SCHEMA_TEMPLATE_DB_NAME) is False:
|
||||
raise RuntimeError(
|
||||
f"Business schema template {settings.WNDB_SCHEMA_TEMPLATE_DB_NAME!r} does not exist"
|
||||
)
|
||||
require_timescale_schema_template()
|
||||
ensure_replication_worker_capacity()
|
||||
if have_project(code):
|
||||
raise ValueError(f"Business database {code!r} already exists")
|
||||
model_template = f"{code}_template"
|
||||
if have_project(model_template):
|
||||
raise ValueError(f"Project model template {model_template!r} already exists")
|
||||
if timescale_database_exists(code):
|
||||
raise ValueError(f"TimescaleDB database {code!r} already exists")
|
||||
geoserver.check_ready()
|
||||
if geoserver.workspace_exists(workspace):
|
||||
raise ValueError(f"GeoServer workspace {workspace!r} already exists")
|
||||
|
||||
|
||||
def provision_project_infrastructure(
|
||||
*,
|
||||
code: str,
|
||||
workspace: str,
|
||||
inp_path: str | Path,
|
||||
) -> ProvisionedProjectInfrastructure:
|
||||
stage = "preflight"
|
||||
business_created = False
|
||||
template_attempted = False
|
||||
timescale_created = False
|
||||
workspace_attempted = False
|
||||
map_bbox: tuple[float, float, float, float] | None = None
|
||||
layers: tuple[str, ...] = ()
|
||||
try:
|
||||
with GeoServerAdminClient() as geoserver:
|
||||
_preflight(code, workspace, geoserver)
|
||||
stage = "business_database"
|
||||
create_project(code)
|
||||
business_created = True
|
||||
|
||||
stage = "model_import"
|
||||
network_update(str(inp_path), code)
|
||||
map_bbox = get_project_map_bbox(code)
|
||||
|
||||
stage = "model_template"
|
||||
template_attempted = True
|
||||
model_template = create_project_model_template(code)
|
||||
|
||||
stage = "timescaledb"
|
||||
create_timescale_database(code)
|
||||
timescale_created = True
|
||||
|
||||
stage = "geoserver"
|
||||
workspace_attempted = True
|
||||
layers = geoserver.create_project_workspace(
|
||||
workspace=workspace,
|
||||
database_name=code,
|
||||
map_bbox=map_bbox,
|
||||
)
|
||||
except Exception as exc:
|
||||
cleanup_errors: list[str] = []
|
||||
if workspace_attempted:
|
||||
try:
|
||||
with GeoServerAdminClient() as geoserver:
|
||||
geoserver.delete_workspace(workspace)
|
||||
except Exception:
|
||||
logger.exception("Failed to remove GeoServer workspace %s", workspace)
|
||||
cleanup_errors.append("geoserver")
|
||||
if timescale_created:
|
||||
try:
|
||||
delete_timescale_database(code)
|
||||
except Exception:
|
||||
logger.exception("Failed to remove TimescaleDB database %s", code)
|
||||
cleanup_errors.append("timescaledb")
|
||||
if template_attempted:
|
||||
try:
|
||||
delete_project_model_template(code)
|
||||
except Exception:
|
||||
logger.exception("Failed to remove project template for %s", code)
|
||||
cleanup_errors.append("model_template")
|
||||
if business_created:
|
||||
try:
|
||||
delete_project(code)
|
||||
except Exception:
|
||||
logger.exception("Failed to remove business database %s", code)
|
||||
cleanup_errors.append("business_database")
|
||||
raise ProjectProvisioningError(stage, exc, cleanup_errors) from exc
|
||||
|
||||
assert map_bbox is not None
|
||||
return ProvisionedProjectInfrastructure(
|
||||
code=code,
|
||||
workspace=workspace,
|
||||
model_template=f"{code}_template",
|
||||
map_bbox=map_bbox,
|
||||
layers=layers,
|
||||
)
|
||||
Reference in New Issue
Block a user