feat(projects): automate project infrastructure provisioning
This commit is contained in:
@@ -9,22 +9,24 @@ from psycopg.rows import dict_row
|
||||
from app.core.config import settings
|
||||
from app.infra.db.project_routing import (
|
||||
get_project_database_name,
|
||||
get_schema_template_database_name,
|
||||
get_project_template_database_name,
|
||||
)
|
||||
|
||||
from .connection import (
|
||||
admin_connection,
|
||||
close_project_pool,
|
||||
project_connection,
|
||||
)
|
||||
|
||||
_SERVER_DATABASES = frozenset({"template0", "template1", "postgres", "project"})
|
||||
_SERVER_DATABASES = frozenset({"template0", "template1", "postgres"})
|
||||
_TEMPORARY_DATABASE_PREFIX = "tjw_tmp_"
|
||||
|
||||
|
||||
def _protected_databases() -> frozenset[str]:
|
||||
return _SERVER_DATABASES | {
|
||||
settings.METADATA_DB_NAME,
|
||||
settings.WNDB_TEMPLATE_DB_NAME,
|
||||
settings.WNDB_SCHEMA_TEMPLATE_DB_NAME,
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +36,7 @@ def _validate_project_database(name: str, *, allow_template_source: bool = False
|
||||
|
||||
protected = {database.casefold() for database in _protected_databases()}
|
||||
is_template = name.casefold().endswith("_template")
|
||||
if (
|
||||
allow_template_source
|
||||
and name.casefold() == settings.WNDB_TEMPLATE_DB_NAME.casefold()
|
||||
):
|
||||
if allow_template_source and is_template:
|
||||
return
|
||||
if name.casefold() in protected or is_template:
|
||||
raise ValueError(f"Database {name!r} is protected and cannot be managed as a project")
|
||||
@@ -120,6 +119,72 @@ def _set_database_connections(cur, database_name: str, *, allowed: bool) -> None
|
||||
)
|
||||
|
||||
|
||||
def _is_project_model_template(database_name: str) -> bool:
|
||||
return (
|
||||
database_name.casefold().endswith("_template")
|
||||
and database_name.casefold()
|
||||
!= get_schema_template_database_name().casefold()
|
||||
)
|
||||
|
||||
|
||||
def _ensure_project_model_template_ready(database_name: str) -> None:
|
||||
"""Reject cloning a missing, disabled, or initially syncing subscription."""
|
||||
if not _is_project_model_template(database_name):
|
||||
return
|
||||
|
||||
try:
|
||||
with project_connection(database_name) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
select count(distinct subscription_row.oid) as subscriptions,
|
||||
count(distinct subscription_row.oid)
|
||||
filter (where subscription_row.subenabled)
|
||||
as enabled_subscriptions,
|
||||
count(subscription_status.pid)
|
||||
filter (where subscription_status.pid is not null)
|
||||
as active_workers
|
||||
from pg_subscription subscription_row
|
||||
left join pg_stat_subscription subscription_status
|
||||
on subscription_status.subid = subscription_row.oid
|
||||
where subscription_row.subdbid = (
|
||||
select oid from pg_database
|
||||
where datname = current_database()
|
||||
)
|
||||
"""
|
||||
)
|
||||
status = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
select count(*) as relations,
|
||||
count(*) filter (where srsubstate <> 'r')
|
||||
as pending_relations
|
||||
from pg_subscription_rel
|
||||
"""
|
||||
)
|
||||
relations = cur.fetchone()
|
||||
finally:
|
||||
close_project_pool(database_name)
|
||||
|
||||
if (
|
||||
status is None
|
||||
or int(status["subscriptions"]) != 1
|
||||
or int(status["enabled_subscriptions"]) != 1
|
||||
or int(status["active_workers"]) < 1
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Project model template {database_name!r} subscription is not active"
|
||||
)
|
||||
if (
|
||||
relations is None
|
||||
or int(relations["relations"]) < 1
|
||||
or int(relations["pending_relations"]) > 0
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Project model template {database_name!r} is still synchronizing"
|
||||
)
|
||||
|
||||
|
||||
def temporary_project_name(project: str, purpose: str) -> str:
|
||||
"""Return a collision-resistant physical database name for one run."""
|
||||
physical_name = get_project_database_name(project)
|
||||
@@ -137,18 +202,11 @@ def temporary_project_database(project: str, purpose: str):
|
||||
"""Clone one project's runnable model into an isolated temporary database."""
|
||||
temporary_name = temporary_project_name(project, purpose)
|
||||
try:
|
||||
copy_project(get_project_template_database_name(project), temporary_name)
|
||||
# Import lazily to keep physical database lifecycle independent from
|
||||
# model-copy implementation details at module import time.
|
||||
from .database import refresh_materialized_views_after_commit
|
||||
from .model_replace import replace_project_model
|
||||
|
||||
replace_project_model(
|
||||
copy_project(
|
||||
get_project_template_database_name(project),
|
||||
temporary_name,
|
||||
project,
|
||||
copy_source_scada=True,
|
||||
allow_template_source=True,
|
||||
)
|
||||
refresh_materialized_views_after_commit(temporary_name)
|
||||
yield temporary_name
|
||||
finally:
|
||||
if have_project(temporary_name):
|
||||
@@ -160,7 +218,11 @@ def temporary_template_database(name_hint: str, purpose: str):
|
||||
"""Create an empty schema-only temporary database from the fixed template."""
|
||||
temporary_name = temporary_project_name(name_hint, purpose)
|
||||
try:
|
||||
copy_project(get_project_template_database_name(name_hint), temporary_name)
|
||||
copy_project(
|
||||
get_schema_template_database_name(),
|
||||
temporary_name,
|
||||
allow_template_source=True,
|
||||
)
|
||||
yield temporary_name
|
||||
finally:
|
||||
if have_project(temporary_name):
|
||||
@@ -175,11 +237,28 @@ def have_project(name: str) -> bool:
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def copy_project(source: str, new: str) -> None:
|
||||
def copy_project(
|
||||
source: str,
|
||||
new: str,
|
||||
*,
|
||||
allow_template_source: bool = False,
|
||||
allow_template_target: bool = False,
|
||||
) -> None:
|
||||
physical_source = get_project_database_name(source)
|
||||
physical_new = get_project_database_name(new)
|
||||
_validate_project_database(physical_source, allow_template_source=True)
|
||||
_validate_project_database(physical_new)
|
||||
_validate_project_database(
|
||||
physical_source,
|
||||
allow_template_source=allow_template_source,
|
||||
)
|
||||
if physical_new.casefold() == get_schema_template_database_name().casefold():
|
||||
raise ValueError(
|
||||
f"Database {physical_new!r} is the protected schema template"
|
||||
)
|
||||
_validate_project_database(
|
||||
physical_new,
|
||||
allow_template_source=allow_template_target,
|
||||
)
|
||||
_ensure_project_model_template_ready(physical_source)
|
||||
close_project_pool(source)
|
||||
close_project_pool(new)
|
||||
|
||||
@@ -216,12 +295,23 @@ def copy_project(source: str, new: str) -> None:
|
||||
|
||||
|
||||
def create_project(name: str) -> None:
|
||||
return copy_project(get_project_template_database_name(name), name)
|
||||
return copy_project(
|
||||
get_schema_template_database_name(),
|
||||
name,
|
||||
allow_template_source=True,
|
||||
)
|
||||
|
||||
|
||||
def delete_project(name: str) -> None:
|
||||
def delete_project(name: str, *, allow_template: bool = False) -> None:
|
||||
database_name = get_project_database_name(name)
|
||||
_validate_project_database(database_name)
|
||||
if database_name.casefold() == get_schema_template_database_name().casefold():
|
||||
raise ValueError(
|
||||
f"Database {database_name!r} is the protected schema template"
|
||||
)
|
||||
_validate_project_database(
|
||||
database_name,
|
||||
allow_template_source=allow_template,
|
||||
)
|
||||
close_project_pool(name)
|
||||
with admin_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
|
||||
Reference in New Issue
Block a user