Skip to content

Commit 5294bf1

Browse files
committed
cache external postgres port in Branch model
1 parent 5fc2dcd commit 5294bf1

3 files changed

Lines changed: 148 additions & 1 deletion

File tree

src/api/organization/project/branch/__init__.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,20 @@ async def _persist_branch_status(branch_id: Identifier, status: BranchServiceSta
158158
await session.commit()
159159

160160

161+
async def _persist_branch_db_port(branch_id: Identifier) -> None:
162+
port = await _resolve_branch_db_port(branch_id)
163+
if port is None:
164+
logger.warning("Could not resolve db port for branch %s", branch_id)
165+
return
166+
async with AsyncSessionLocal() as session:
167+
branch = await session.get(Branch, branch_id)
168+
if branch is None:
169+
logger.warning("Branch %s missing while persisting db_port", branch_id)
170+
return
171+
branch.db_port = port
172+
await session.commit()
173+
174+
161175
async def _cleanup_failed_branch_deployment(branch_id: Identifier) -> None:
162176
"""Best-effort cleanup when provisioning fails so allocations aren't stranded."""
163177
try:
@@ -715,6 +729,7 @@ async def _deploy_branch_environment_task(
715729
branch_slug,
716730
)
717731
return
732+
await _persist_branch_db_port(branch_id)
718733
await _persist_branch_status(branch_id, BranchServiceStatus.STARTING)
719734

720735

@@ -788,6 +803,7 @@ async def _clone_branch_environment_task(
788803
branch_slug,
789804
)
790805
return
806+
await _persist_branch_db_port(branch_id)
791807
await _persist_branch_status(branch_id, BranchServiceStatus.STARTING)
792808

793809

@@ -868,6 +884,7 @@ async def _restore_branch_environment_task(
868884
branch_slug,
869885
)
870886
return
887+
await _persist_branch_db_port(branch_id)
871888
await _persist_branch_status(branch_id, BranchServiceStatus.STARTING)
872889

873890

@@ -1009,7 +1026,7 @@ async def _public(branch: Branch) -> BranchPublic:
10091026
project = await branch.awaitable_attrs.project
10101027

10111028
db_host = _resolve_db_host(branch) or ""
1012-
port = (await _resolve_branch_db_port(branch.id)) or 0
1029+
port = branch.db_port or (await _resolve_branch_db_port(branch.id)) or 0
10131030

10141031
# pg-meta and pg are in the same network. So password is not required in connection string.
10151032
connection_string = _build_connection_string("vela", "postgres", 5432)

src/models/branch.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from enum import StrEnum
44
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Optional
55

6+
import sqlalchemy as sa
67
from pydantic import BaseModel, model_validator
78
from pydantic import Field as PydanticField
89
from sqlalchemy import BigInteger, Boolean, Column, String, Text, UniqueConstraint, text
@@ -108,6 +109,7 @@ class Branch(AsyncAttrs, Model, table=True):
108109
)
109110
pitr_enabled: bool = Field(default=False, sa_column=Column(Boolean, nullable=False, server_default=text("false")))
110111
resize_task_id: uuid.UUID | None = Field(default=None, nullable=True)
112+
db_port: int | None = Field(default=None, sa_column=Column(sa.Integer, nullable=True))
111113

112114
__table_args__ = (UniqueConstraint("project_id", "name", name="unique_branch_name_per_project"),)
113115

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Add and backfill branch.db_port
2+
3+
Revision ID: 2b4e8f1a6c03
4+
Revises: f4f677e4e9b9
5+
Create Date: 2026-03-19 00:00:00.000000
6+
7+
"""
8+
import logging
9+
import os
10+
from typing import Sequence, Union
11+
from uuid import UUID
12+
13+
from alembic import op
14+
import sqlalchemy as sa
15+
from kubernetes import client as kubernetes_client
16+
from kubernetes import config as kubernetes_config
17+
from kubernetes.config.config_exception import ConfigException
18+
from ulid import ULID
19+
20+
logger = logging.getLogger(__name__)
21+
22+
# revision identifiers, used by Alembic.
23+
revision: str = '2b4e8f1a6c03'
24+
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
25+
branch_labels: Union[str, Sequence[str], None] = None
26+
depends_on: Union[str, Sequence[str], None] = None
27+
28+
_CHART_NAME = "vela"
29+
30+
31+
def _ensure_kube_client_config() -> None:
32+
try:
33+
kubernetes_config.load_incluster_config()
34+
except ConfigException:
35+
try:
36+
kubernetes_config.load_kube_config()
37+
except ConfigException as exc:
38+
raise RuntimeError("Kubernetes client not configured. Mount kubeconfig or run in-cluster.") from exc
39+
40+
41+
def _uuid_to_ulid_str(uuid_str: str) -> str:
42+
"""Convert a UUID string (as stored in the DB) back to a ULID string."""
43+
return str(ULID.from_bytes(UUID(uuid_str).bytes))
44+
45+
46+
def _deployment_namespace(branch_id: str) -> str:
47+
prefix = os.environ.get("VELA_DEPLOYMENT_NAMESPACE_PREFIX", "vela")
48+
ulid_str = _uuid_to_ulid_str(branch_id).lower()
49+
return f"{prefix}-{ulid_str}" if prefix else ulid_str
50+
51+
52+
def _release_fullname() -> str:
53+
release = os.environ.get("VELA_DEPLOYMENT_RELEASE_NAME", "vela")
54+
return release if _CHART_NAME in release else f"{release}-{_CHART_NAME}"
55+
56+
57+
def _branch_service_name(component: str) -> str:
58+
return f"{_release_fullname()}-{component}"
59+
60+
61+
def _get_all_node_ports() -> dict[str, int]:
62+
"""Fetch node ports for all pgbouncer/db services in a single API call.
63+
64+
Returns a mapping of namespace -> nodePort, preferring the pgbouncer service
65+
over the db service when both exist in the same namespace.
66+
"""
67+
core_v1 = kubernetes_client.CoreV1Api()
68+
target_names = {_branch_service_name("pgbouncer"), _branch_service_name("db")}
69+
70+
svc_list = core_v1.list_service_for_all_namespaces()
71+
72+
ports_by_namespace: dict[str, int] = {}
73+
for svc in svc_list.items:
74+
if svc.metadata.name not in target_names:
75+
continue
76+
ns = svc.metadata.namespace
77+
if ns in ports_by_namespace:
78+
continue # already resolved for this namespace
79+
ports = svc.spec.ports
80+
if ports and ports[0].node_port:
81+
ports_by_namespace[ns] = ports[0].node_port
82+
83+
return ports_by_namespace
84+
85+
86+
def upgrade() -> None:
87+
"""Upgrade schema."""
88+
op.add_column(
89+
'branch',
90+
sa.Column('db_port', sa.Integer(), nullable=True),
91+
)
92+
93+
bind = op.get_bind()
94+
branch_rows = bind.execute(
95+
sa.text("SELECT id::text AS id FROM branch")
96+
).mappings().all()
97+
98+
if not branch_rows:
99+
return
100+
101+
_ensure_kube_client_config()
102+
ports_by_namespace = _get_all_node_ports()
103+
104+
updates = []
105+
for row in branch_rows:
106+
namespace = _deployment_namespace(row["id"])
107+
port = ports_by_namespace.get(namespace)
108+
if port is None:
109+
logger.warning("Could not resolve db_port for branch %s — skipping", row["id"])
110+
continue
111+
updates.append({"id": row["id"], "db_port": port})
112+
113+
if updates:
114+
bind.execute(
115+
sa.text(
116+
"""
117+
UPDATE branch
118+
SET db_port = :db_port
119+
WHERE id = CAST(:id AS uuid)
120+
"""
121+
),
122+
updates,
123+
)
124+
125+
126+
def downgrade() -> None:
127+
"""Downgrade schema."""
128+
op.drop_column('branch', 'db_port')

0 commit comments

Comments
 (0)