Skip to content

Commit 4094853

Browse files
authored
fix: Readd database support for resource manager (#333)
1 parent 87e8378 commit 4094853

File tree

17 files changed

+965
-13
lines changed

17 files changed

+965
-13
lines changed

src/firebolt/model/V1/database.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from datetime import datetime
5+
from typing import TYPE_CHECKING, Any, List, Optional, Sequence
6+
7+
from pydantic import Field, PrivateAttr
8+
9+
from firebolt.model.V1 import FireboltBaseModel
10+
from firebolt.model.V1.region import RegionKey
11+
from firebolt.service.V1.engine import EngineService
12+
from firebolt.service.V1.types import EngineStatusSummary
13+
from firebolt.utils.exception import AttachedEngineInUseError
14+
from firebolt.utils.urls import ACCOUNT_DATABASE_URL
15+
16+
if TYPE_CHECKING:
17+
from firebolt.model.V1.binding import Binding
18+
from firebolt.model.V1.engine import Engine
19+
from firebolt.service.V1.database import DatabaseService
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class DatabaseKey(FireboltBaseModel):
25+
account_id: str
26+
database_id: str
27+
28+
29+
class FieldMask(FireboltBaseModel):
30+
paths: Sequence[str] = Field(alias="paths")
31+
32+
33+
class Database(FireboltBaseModel):
34+
"""
35+
A Firebolt database.
36+
37+
Databases belong to a region and have a description,
38+
but otherwise are not configurable.
39+
"""
40+
41+
# internal
42+
_service: DatabaseService = PrivateAttr()
43+
44+
# required
45+
name: str = Field(min_length=1, max_length=255, regex=r"^[0-9a-zA-Z_]+$")
46+
compute_region_key: RegionKey = Field(alias="compute_region_id")
47+
48+
# optional
49+
database_key: Optional[DatabaseKey] = Field(None, alias="id")
50+
description: Optional[str] = Field(None, max_length=255)
51+
emoji: Optional[str] = Field(None, max_length=255)
52+
current_status: Optional[str]
53+
health_status: Optional[str]
54+
data_size_full: Optional[int]
55+
data_size_compressed: Optional[int]
56+
is_system_database: Optional[bool]
57+
storage_bucket_name: Optional[str]
58+
create_time: Optional[datetime]
59+
create_actor: Optional[str]
60+
last_update_time: Optional[datetime]
61+
last_update_actor: Optional[str]
62+
desired_status: Optional[str]
63+
64+
@classmethod
65+
def parse_obj_with_service(
66+
cls, obj: Any, database_service: DatabaseService
67+
) -> Database:
68+
database = cls.parse_obj(obj)
69+
database._service = database_service
70+
return database
71+
72+
@property
73+
def database_id(self) -> Optional[str]:
74+
if self.database_key is None:
75+
return None
76+
return self.database_key.database_id
77+
78+
def get_attached_engines(self) -> List[Engine]:
79+
"""Get a list of engines that are attached to this database."""
80+
81+
return self._service.resource_manager.bindings.get_engines_bound_to_database( # noqa: E501
82+
database=self
83+
)
84+
85+
def attach_to_engine(
86+
self, engine: Engine, is_default_engine: bool = False
87+
) -> Binding:
88+
"""
89+
Attach an engine to this database.
90+
91+
Args:
92+
engine: The engine to attach.
93+
is_default_engine:
94+
Whether this engine should be used as default for this database.
95+
Only one engine can be set as default for a single database.
96+
This will overwrite any existing default.
97+
"""
98+
99+
return self._service.resource_manager.bindings.create(
100+
engine=engine, database=self, is_default_engine=is_default_engine
101+
)
102+
103+
def delete(self) -> Database:
104+
"""
105+
Delete a database from Firebolt.
106+
107+
Raises an error if there are any attached engines.
108+
"""
109+
110+
for engine in self.get_attached_engines():
111+
if engine.current_status_summary in {
112+
EngineStatusSummary.ENGINE_STATUS_SUMMARY_STARTING,
113+
EngineStatusSummary.ENGINE_STATUS_SUMMARY_STOPPING,
114+
}:
115+
raise AttachedEngineInUseError(method_name="delete")
116+
117+
logger.info(
118+
f"Deleting Database (database_id={self.database_id}, name={self.name})"
119+
)
120+
response = self._service.client.delete(
121+
url=ACCOUNT_DATABASE_URL.format(
122+
account_id=self._service.account_id, database_id=self.database_id
123+
),
124+
headers={"Content-type": "application/json"},
125+
)
126+
return Database.parse_obj_with_service(
127+
response.json()["database"], self._service
128+
)
129+
130+
def update(self, description: str) -> Database:
131+
"""
132+
Updates a database description.
133+
"""
134+
135+
class _DatabaseUpdateRequest(FireboltBaseModel):
136+
"""Helper model for sending Database creation requests."""
137+
138+
account_id: str
139+
database: Database
140+
database_id: str
141+
update_mask: FieldMask
142+
143+
self.description = description
144+
145+
logger.info(
146+
f"Updating Database (database_id={self.database_id}, "
147+
f"name={self.name}, description={self.description})"
148+
)
149+
150+
payload = _DatabaseUpdateRequest(
151+
account_id=self._service.account_id,
152+
database=self,
153+
database_id=self.database_id,
154+
update_mask=FieldMask(paths=["description"]),
155+
).jsonable_dict(by_alias=True)
156+
157+
response = self._service.client.patch(
158+
url=ACCOUNT_DATABASE_URL.format(
159+
account_id=self._service.account_id, database_id=self.database_id
160+
),
161+
headers={"Content-type": "application/json"},
162+
json=payload,
163+
)
164+
165+
return Database.parse_obj_with_service(
166+
response.json()["database"], self._service
167+
)
168+
169+
def get_default_engine(self) -> Optional[Engine]:
170+
"""
171+
Returns: default engine of the database, or None if default engine is missing
172+
"""
173+
rm = self._service.resource_manager
174+
assert isinstance(rm.engines, EngineService), "Expected EngineService V1"
175+
default_engines: List[Engine] = [
176+
rm.engines.get(binding.engine_id)
177+
for binding in rm.bindings.get_many(database_id=self.database_id)
178+
if binding.is_default_engine
179+
]
180+
181+
return None if len(default_engines) == 0 else default_engines[0]

src/firebolt/model/V1/provider.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from datetime import datetime
2+
from typing import Optional
3+
4+
from pydantic import Field
5+
6+
from firebolt.model.V1 import FireboltBaseModel
7+
8+
9+
class Provider(FireboltBaseModel, frozen=True): # type: ignore
10+
provider_id: str = Field(alias="id")
11+
name: str
12+
13+
# optional
14+
create_time: Optional[datetime]
15+
display_name: Optional[str]
16+
last_update_time: Optional[datetime]

src/firebolt/service/V1/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Optional
2+
13
from firebolt.client import ClientV1 as Client
24
from firebolt.service.manager import ResourceManager
35

@@ -13,3 +15,7 @@ def client(self) -> Client:
1315
@property
1416
def account_id(self) -> str:
1517
return self.resource_manager.account_id
18+
19+
@property
20+
def default_region_setting(self) -> Optional[str]:
21+
return self.resource_manager.default_region

src/firebolt/service/V1/binding.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,35 @@
11
import logging
22
from typing import List, Optional
33

4-
from firebolt.model.V1.binding import Binding
4+
from firebolt.model.V1.binding import Binding, BindingKey
5+
from firebolt.model.V1.database import Database
6+
from firebolt.model.V1.engine import Engine
57
from firebolt.service.V1.base import BaseService
6-
from firebolt.utils.urls import ACCOUNT_BINDINGS_URL
8+
from firebolt.service.V1.database import DatabaseService
9+
from firebolt.service.V1.engine import EngineService
10+
from firebolt.utils.exception import AlreadyBoundError
11+
from firebolt.utils.urls import (
12+
ACCOUNT_BINDINGS_URL,
13+
ACCOUNT_DATABASE_BINDING_URL,
14+
)
715
from firebolt.utils.util import prune_dict
816

917
logger = logging.getLogger(__name__)
1018

1119

1220
class BindingService(BaseService):
21+
def get_by_key(self, binding_key: BindingKey) -> Binding:
22+
"""Get a binding by its BindingKey"""
23+
response = self.client.get(
24+
url=ACCOUNT_DATABASE_BINDING_URL.format(
25+
account_id=binding_key.account_id,
26+
database_id=binding_key.database_id,
27+
engine_id=binding_key.engine_id,
28+
)
29+
)
30+
binding: dict = response.json()["binding"]
31+
return Binding.parse_obj(binding)
32+
1333
def get_many(
1434
self,
1535
database_id: Optional[str] = None,
@@ -47,3 +67,81 @@ def get_many(
4767
),
4868
)
4969
return [Binding.parse_obj(i["node"]) for i in response.json()["edges"]]
70+
71+
def get_database_bound_to_engine(self, engine: Engine) -> Optional[Database]:
72+
"""Get the database to which an engine is bound, if any."""
73+
try:
74+
binding = self.get_many(engine_id=engine.engine_id)[0]
75+
except IndexError:
76+
return None
77+
try:
78+
assert isinstance(
79+
self.resource_manager.databases, DatabaseService
80+
), "Expected DatabaseService V1"
81+
return self.resource_manager.databases.get(id_=binding.database_id)
82+
except (KeyError, IndexError):
83+
return None
84+
85+
def get_engines_bound_to_database(self, database: Database) -> List[Engine]:
86+
"""Get a list of engines that are bound to a database."""
87+
88+
bindings = self.get_many(database_id=database.database_id)
89+
if not bindings:
90+
return []
91+
assert isinstance(
92+
self.resource_manager.engines, EngineService
93+
), "Expected EngineService V1"
94+
return self.resource_manager.engines.get_by_ids(
95+
ids=[b.engine_id for b in bindings]
96+
)
97+
98+
def create(
99+
self, engine: Engine, database: Database, is_default_engine: bool
100+
) -> Binding:
101+
"""
102+
Create a new binding between an engine and a database.
103+
104+
Args:
105+
engine: Engine to bind.
106+
database: Database to bind.
107+
is_default_engine:
108+
Whether this engine should be used as default for this database.
109+
Only one engine can be set as default for a single database.
110+
This will overwrite any existing default.
111+
112+
Returns:
113+
New binding between the engine and database.
114+
"""
115+
116+
existing_database = self.get_database_bound_to_engine(engine=engine)
117+
if existing_database is not None:
118+
raise AlreadyBoundError(
119+
f"The engine {engine.name} is already bound "
120+
f"to {existing_database.name}!"
121+
)
122+
123+
logger.info(
124+
f"Attaching Engine (engine_id={engine.engine_id}, name={engine.name}) "
125+
f"to Database (database_id={database.database_id}, "
126+
f"name={database.name})"
127+
)
128+
binding = Binding(
129+
binding_key=BindingKey(
130+
account_id=self.account_id,
131+
database_id=database.database_id,
132+
engine_id=engine.engine_id,
133+
),
134+
is_default_engine=is_default_engine,
135+
)
136+
137+
response = self.client.post(
138+
url=ACCOUNT_DATABASE_BINDING_URL.format(
139+
account_id=self.account_id,
140+
database_id=database.database_id,
141+
engine_id=engine.engine_id,
142+
),
143+
json=binding.jsonable_dict(
144+
by_alias=True, include={"binding_key": ..., "is_default_engine": ...}
145+
),
146+
)
147+
return Binding.parse_obj(response.json()["binding"])

0 commit comments

Comments
 (0)