dbos 0.17.0a4__py3-none-any.whl → 0.18.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dbos might be problematic. Click here for more details.

@@ -0,0 +1,163 @@
1
+ import os
2
+ import time
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+
6
+ import jwt
7
+ import requests
8
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
9
+ from cryptography.x509 import load_pem_x509_certificate
10
+ from rich import print
11
+
12
+ from .._logger import dbos_logger
13
+
14
+ # Constants
15
+ DBOS_CLOUD_HOST = os.getenv("DBOS_DOMAIN", "cloud.dbos.dev")
16
+ PRODUCTION_ENVIRONMENT = DBOS_CLOUD_HOST == "cloud.dbos.dev"
17
+ AUTH0_DOMAIN = "login.dbos.dev" if PRODUCTION_ENVIRONMENT else "dbos-inc.us.auth0.com"
18
+ DBOS_CLIENT_ID = (
19
+ "6p7Sjxf13cyLMkdwn14MxlH7JdhILled"
20
+ if PRODUCTION_ENVIRONMENT
21
+ else "G38fLmVErczEo9ioCFjVIHea6yd0qMZu"
22
+ )
23
+ DBOS_CLOUD_IDENTIFIER = "dbos-cloud-api"
24
+
25
+
26
+ @dataclass
27
+ class DeviceCodeResponse:
28
+ device_code: str
29
+ user_code: str
30
+ verification_uri: str
31
+ verification_uri_complete: str
32
+ expires_in: int
33
+ interval: int
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: Dict[str, Any]) -> "DeviceCodeResponse":
37
+ return cls(
38
+ device_code=data["device_code"],
39
+ user_code=data["user_code"],
40
+ verification_uri=data["verification_uri"],
41
+ verification_uri_complete=data["verification_uri_complete"],
42
+ expires_in=data["expires_in"],
43
+ interval=data["interval"],
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class TokenResponse:
49
+ access_token: str
50
+ token_type: str
51
+ expires_in: int
52
+ refresh_token: Optional[str] = None
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: Dict[str, Any]) -> "TokenResponse":
56
+ return cls(
57
+ access_token=data["access_token"],
58
+ token_type=data["token_type"],
59
+ expires_in=data["expires_in"],
60
+ refresh_token=data.get("refresh_token"),
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class AuthenticationResponse:
66
+ token: str
67
+ refresh_token: Optional[str] = None
68
+
69
+
70
+ class JWKSClient:
71
+ def __init__(self, jwks_uri: str):
72
+ self.jwks_uri = jwks_uri
73
+
74
+ def get_signing_key(self, kid: str) -> RSAPublicKey:
75
+ response = requests.get(self.jwks_uri)
76
+ jwks = response.json()
77
+ for key in jwks["keys"]:
78
+ if key["kid"] == kid:
79
+ cert_text = f"-----BEGIN CERTIFICATE-----\n{key['x5c'][0]}\n-----END CERTIFICATE-----"
80
+ cert = load_pem_x509_certificate(cert_text.encode())
81
+ return cert.public_key() # type: ignore
82
+ raise Exception(f"Unable to find signing key with kid: {kid}")
83
+
84
+
85
+ def verify_token(token: str) -> None:
86
+ header = jwt.get_unverified_header(token)
87
+
88
+ if not header.get("kid"):
89
+ raise ValueError("Invalid token: No 'kid' in header")
90
+
91
+ client = JWKSClient(f"https://{AUTH0_DOMAIN}/.well-known/jwks.json")
92
+ signing_key = client.get_signing_key(header["kid"])
93
+ jwt.decode(
94
+ token,
95
+ signing_key,
96
+ algorithms=["RS256"],
97
+ audience=DBOS_CLOUD_IDENTIFIER,
98
+ options={
99
+ "verify_iat": False,
100
+ "clock_tolerance": 60,
101
+ },
102
+ )
103
+
104
+
105
+ def authenticate(get_refresh_token: bool = False) -> Optional[AuthenticationResponse]:
106
+ print(
107
+ "[bold blue]Please authenticate with DBOS Cloud to access a Postgres database[/bold blue]"
108
+ )
109
+
110
+ # Get device code
111
+ device_code_data = {
112
+ "client_id": DBOS_CLIENT_ID,
113
+ "scope": "offline_access" if get_refresh_token else "sub",
114
+ "audience": DBOS_CLOUD_IDENTIFIER,
115
+ }
116
+
117
+ try:
118
+ response = requests.post(
119
+ f"https://{AUTH0_DOMAIN}/oauth/device/code",
120
+ data=device_code_data,
121
+ headers={"content-type": "application/x-www-form-urlencoded"},
122
+ )
123
+ device_code_response = DeviceCodeResponse.from_dict(response.json())
124
+ except Exception as e:
125
+ dbos_logger.error(f"Failed to log in: {str(e)}")
126
+ return None
127
+
128
+ login_url = device_code_response.verification_uri_complete
129
+ print(f"[bold blue]Login URL:[/bold blue] {login_url}")
130
+
131
+ # Poll for token
132
+ token_data = {
133
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
134
+ "device_code": device_code_response.device_code,
135
+ "client_id": DBOS_CLIENT_ID,
136
+ }
137
+
138
+ elapsed_time_sec = 0
139
+ token_response = None
140
+
141
+ while elapsed_time_sec < device_code_response.expires_in:
142
+ try:
143
+ time.sleep(device_code_response.interval)
144
+ elapsed_time_sec += device_code_response.interval
145
+
146
+ response = requests.post(
147
+ f"https://{AUTH0_DOMAIN}/oauth/token",
148
+ data=token_data,
149
+ headers={"content-type": "application/x-www-form-urlencoded"},
150
+ )
151
+ if response.status_code == 200:
152
+ token_response = TokenResponse.from_dict(response.json())
153
+ break
154
+ except Exception:
155
+ dbos_logger.info("Waiting for login...")
156
+
157
+ if not token_response:
158
+ return None
159
+
160
+ verify_token(token_response.access_token)
161
+ return AuthenticationResponse(
162
+ token=token_response.access_token, refresh_token=token_response.refresh_token
163
+ )
@@ -0,0 +1,252 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import time
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any, Optional, Union
8
+
9
+ import jwt
10
+ import requests
11
+ import typer
12
+ from rich import print
13
+
14
+ from dbos._error import DBOSInitializationError
15
+
16
+ from .._logger import dbos_logger
17
+ from .authentication import authenticate
18
+
19
+
20
+ # Must be the same as in TS
21
+ @dataclass
22
+ class DBOSCloudCredentials:
23
+ token: str
24
+ userName: str
25
+ organization: str
26
+ refreshToken: Optional[str] = None
27
+
28
+
29
+ @dataclass
30
+ class UserProfile:
31
+ Name: str
32
+ Email: str
33
+ Organization: str
34
+ SubscriptionPlan: str
35
+
36
+
37
+ class AppLanguages(Enum):
38
+ Node = "node"
39
+ Python = "python"
40
+
41
+
42
+ dbos_config_file_path = "dbos-config.yaml"
43
+ DBOS_CLOUD_HOST = os.getenv("DBOS_DOMAIN", "cloud.dbos.dev")
44
+ dbos_env_path = ".dbos"
45
+
46
+
47
+ def is_token_expired(token: str) -> bool:
48
+ try:
49
+ decoded = jwt.decode(token, options={"verify_signature": False})
50
+ exp: int = decoded.get("exp")
51
+ if not exp:
52
+ return False
53
+ return time.time() >= exp
54
+ except Exception:
55
+ return True
56
+
57
+
58
+ def credentials_exist() -> bool:
59
+ return os.path.exists(os.path.join(dbos_env_path, "credentials"))
60
+
61
+
62
+ def delete_credentials() -> None:
63
+ credentials_path = os.path.join(dbos_env_path, "credentials")
64
+ if os.path.exists(credentials_path):
65
+ os.unlink(credentials_path)
66
+
67
+
68
+ def write_credentials(credentials: DBOSCloudCredentials) -> None:
69
+ os.makedirs(dbos_env_path, exist_ok=True)
70
+ with open(os.path.join(dbos_env_path, "credentials"), "w", encoding="utf-8") as f:
71
+ json.dump(credentials.__dict__, f)
72
+
73
+
74
+ def check_read_file(path: str, encoding: str = "utf-8") -> Union[str, bytes]:
75
+ # Check if file exists and is a file
76
+ if not os.path.exists(path):
77
+ raise FileNotFoundError(f"File {path} does not exist")
78
+ if not os.path.isfile(path):
79
+ raise IsADirectoryError(f"Path {path} is not a file")
80
+
81
+ # Read file content
82
+ with open(path, encoding=encoding) as f:
83
+ return f.read()
84
+
85
+
86
+ @dataclass
87
+ class CloudAPIErrorResponse:
88
+ message: str
89
+ status_code: int
90
+ request_id: str
91
+ detailed_error: Optional[str] = None
92
+
93
+
94
+ def is_cloud_api_error_response(obj: Any) -> bool:
95
+ return (
96
+ isinstance(obj, dict)
97
+ and "message" in obj
98
+ and isinstance(obj["message"], str)
99
+ and "statusCode" in obj
100
+ and isinstance(obj["statusCode"], int)
101
+ and "requestID" in obj
102
+ and isinstance(obj["requestID"], str)
103
+ )
104
+
105
+
106
+ def handle_api_errors(label: str, e: requests.exceptions.RequestException) -> None:
107
+ if hasattr(e, "response") and e.response is not None:
108
+ resp = e.response.json()
109
+ if is_cloud_api_error_response(resp):
110
+ message = f"[{resp['requestID']}] {label}: {resp['message']}."
111
+ dbos_logger.error(message)
112
+ raise DBOSInitializationError(message)
113
+
114
+
115
+ def is_valid_username(value: str) -> Union[bool, str]:
116
+ if len(value) < 3 or len(value) > 30:
117
+ return "Username must be 3~30 characters long"
118
+ if not re.match("^[a-z0-9_]+$", value):
119
+ return "Username must contain only lowercase letters, numbers, and underscores."
120
+ return True
121
+
122
+
123
+ def check_user_profile(credentials: DBOSCloudCredentials) -> bool:
124
+ bearer_token = f"Bearer {credentials.token}"
125
+ try:
126
+ response = requests.get(
127
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/user/profile",
128
+ headers={
129
+ "Content-Type": "application/json",
130
+ "Authorization": bearer_token,
131
+ },
132
+ )
133
+ response.raise_for_status()
134
+ profile = UserProfile(**response.json())
135
+ credentials.userName = profile.Name
136
+ credentials.organization = profile.Organization
137
+ return True
138
+ except requests.exceptions.RequestException as e:
139
+ error_label = "Failed to login"
140
+ if hasattr(e, "response") and e.response is not None:
141
+ resp = e.response.json()
142
+ if is_cloud_api_error_response(resp):
143
+ if "user not found in DBOS Cloud" not in resp["message"]:
144
+ handle_api_errors(error_label, e)
145
+ exit(1)
146
+ else:
147
+ dbos_logger.error(f"{error_label}: {str(e)}")
148
+ exit(1)
149
+ return False
150
+
151
+
152
+ def register_user(credentials: DBOSCloudCredentials) -> None:
153
+ print("Please register for DBOS Cloud")
154
+
155
+ user_name = None
156
+ while not user_name:
157
+ user_name = typer.prompt("Choose your username")
158
+ validation_result = is_valid_username(user_name)
159
+ if validation_result is not True:
160
+ print(f"[red]Invalid username: {validation_result}[/red]")
161
+ user_name = None
162
+ continue
163
+
164
+ bearer_token = f"Bearer {credentials.token}"
165
+ try:
166
+ # Register user
167
+ response = requests.put(
168
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/user",
169
+ json={
170
+ "name": user_name,
171
+ },
172
+ headers={
173
+ "Content-Type": "application/json",
174
+ "Authorization": bearer_token,
175
+ },
176
+ )
177
+ response.raise_for_status()
178
+
179
+ # Get user profile
180
+ response = requests.get(
181
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/user/profile",
182
+ headers={
183
+ "Content-Type": "application/json",
184
+ "Authorization": bearer_token,
185
+ },
186
+ )
187
+ response.raise_for_status()
188
+ profile = UserProfile(**response.json())
189
+ credentials.userName = profile.Name
190
+ credentials.organization = profile.Organization
191
+ print(f"[green]Successfully registered as {credentials.userName}[/green]")
192
+
193
+ except requests.exceptions.RequestException as e:
194
+ error_label = f"Failed to register user {user_name}"
195
+ if hasattr(e, "response") and e.response is not None:
196
+ handle_api_errors(error_label, e)
197
+ else:
198
+ dbos_logger.error(f"{error_label}: {str(e)}")
199
+ exit(1)
200
+
201
+
202
+ def check_credentials() -> DBOSCloudCredentials:
203
+ empty_credentials = DBOSCloudCredentials(token="", userName="", organization="")
204
+
205
+ if not credentials_exist():
206
+ return empty_credentials
207
+
208
+ try:
209
+ with open(os.path.join(dbos_env_path, "credentials"), "r") as f:
210
+ cred_data = json.load(f)
211
+ credentials = DBOSCloudCredentials(**cred_data)
212
+
213
+ # Trim trailing /r /n
214
+ credentials.token = credentials.token.strip()
215
+
216
+ if is_token_expired(credentials.token):
217
+ print("Credentials expired. Logging in again...")
218
+ delete_credentials()
219
+ return empty_credentials
220
+
221
+ return credentials
222
+ except Exception as e:
223
+ dbos_logger.error(f"Error loading credentials: {str(e)}")
224
+ return empty_credentials
225
+
226
+
227
+ def get_cloud_credentials() -> DBOSCloudCredentials:
228
+ # Check if credentials exist and are not expired
229
+ credentials = check_credentials()
230
+
231
+ # Log in the user
232
+ if not credentials.token:
233
+ auth_response = authenticate()
234
+ if auth_response is None:
235
+ dbos_logger.error("Failed to login. Exiting...")
236
+ exit(1)
237
+ credentials.token = auth_response.token
238
+ credentials.refreshToken = auth_response.refresh_token
239
+ write_credentials(credentials)
240
+
241
+ # Check if the user exists in DBOS Cloud
242
+ user_exists = check_user_profile(credentials)
243
+ if user_exists:
244
+ write_credentials(credentials)
245
+ print(f"[green]Successfully logged in as {credentials.userName}[/green]")
246
+ return credentials
247
+
248
+ # User doesn't exist, register the user in DBOS Cloud
249
+ register_user(credentials)
250
+ write_credentials(credentials)
251
+
252
+ return credentials
@@ -0,0 +1,237 @@
1
+ import base64
2
+ import random
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Any, List, Optional
6
+
7
+ import requests
8
+ from rich import print
9
+
10
+ from dbos._cloudutils.cloudutils import (
11
+ DBOS_CLOUD_HOST,
12
+ DBOSCloudCredentials,
13
+ handle_api_errors,
14
+ is_cloud_api_error_response,
15
+ )
16
+ from dbos._error import DBOSInitializationError
17
+
18
+ from .._logger import dbos_logger
19
+
20
+
21
+ @dataclass
22
+ class UserDBCredentials:
23
+ RoleName: str
24
+ Password: str
25
+
26
+
27
+ @dataclass
28
+ class UserDBInstance:
29
+ PostgresInstanceName: str = ""
30
+ Status: str = ""
31
+ HostName: str = ""
32
+ Port: int = 0
33
+ DatabaseUsername: str = ""
34
+ IsLinked: bool = False
35
+ SupabaseReference: Optional[str] = None
36
+
37
+ def __init__(self, **kwargs: Any) -> None:
38
+ self.PostgresInstanceName = kwargs.get("PostgresInstanceName", "")
39
+ self.Status = kwargs.get("Status", "")
40
+ self.HostName = kwargs.get("HostName", "")
41
+ self.Port = kwargs.get("Port", 0)
42
+ self.DatabaseUsername = kwargs.get("DatabaseUsername", "")
43
+ self.IsLinked = kwargs.get("IsLinked", False)
44
+ self.SupabaseReference = kwargs.get("SupabaseReference", None)
45
+
46
+
47
+ def get_user_db_info(credentials: DBOSCloudCredentials, db_name: str) -> UserDBInstance:
48
+ bearer_token = f"Bearer {credentials.token}"
49
+
50
+ try:
51
+ response = requests.get(
52
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb/info/{db_name}",
53
+ headers={
54
+ "Content-Type": "application/json",
55
+ "Authorization": bearer_token,
56
+ },
57
+ )
58
+ response.raise_for_status()
59
+ data = response.json()
60
+ return UserDBInstance(**data)
61
+ except requests.exceptions.RequestException as e:
62
+ error_label = f"Failed to get status of database {db_name}"
63
+ if hasattr(e, "response") and e.response is not None:
64
+ resp = e.response.json()
65
+ if is_cloud_api_error_response(resp):
66
+ handle_api_errors(error_label, e)
67
+ else:
68
+ dbos_logger.error(f"{error_label}: {str(e)}")
69
+ raise DBOSInitializationError(f"{error_label}: {str(e)}")
70
+
71
+
72
+ def get_user_db_credentials(
73
+ credentials: DBOSCloudCredentials, db_name: str
74
+ ) -> UserDBCredentials:
75
+ bearer_token = f"Bearer {credentials.token}"
76
+ try:
77
+ response = requests.get(
78
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb/{db_name}/credentials",
79
+ headers={
80
+ "Content-Type": "application/json",
81
+ "Authorization": bearer_token,
82
+ },
83
+ )
84
+ response.raise_for_status()
85
+ data = response.json()
86
+ return UserDBCredentials(**data)
87
+ except requests.exceptions.RequestException as e:
88
+ error_label = f"Failed to get credentials for database {db_name}"
89
+ if hasattr(e, "response") and e.response is not None:
90
+ resp = e.response.json()
91
+ if is_cloud_api_error_response(resp):
92
+ handle_api_errors(error_label, e)
93
+ else:
94
+ dbos_logger.error(f"{error_label}: {str(e)}")
95
+ raise DBOSInitializationError(f"{error_label}: {str(e)}")
96
+
97
+
98
+ def create_user_role(credentials: DBOSCloudCredentials, db_name: str) -> None:
99
+ bearer_token = f"Bearer {credentials.token}"
100
+ try:
101
+ response = requests.post(
102
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb/{db_name}/createuserdbrole",
103
+ headers={
104
+ "Content-Type": "application/json",
105
+ "Authorization": bearer_token,
106
+ },
107
+ )
108
+ response.raise_for_status()
109
+ except requests.exceptions.RequestException as e:
110
+ error_label = f"Failed to create a user role for database {db_name}"
111
+ if hasattr(e, "response") and e.response is not None:
112
+ resp = e.response.json()
113
+ if is_cloud_api_error_response(resp):
114
+ handle_api_errors(error_label, e)
115
+ else:
116
+ dbos_logger.error(f"{error_label}: {str(e)}")
117
+ raise DBOSInitializationError(f"{error_label}: {str(e)}")
118
+
119
+
120
+ def create_user_db(
121
+ credentials: DBOSCloudCredentials,
122
+ db_name: str,
123
+ app_db_username: str,
124
+ app_db_password: str,
125
+ ) -> int:
126
+ bearer_token = f"Bearer {credentials.token}"
127
+
128
+ try:
129
+ response = requests.post(
130
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb",
131
+ json={
132
+ "Name": db_name,
133
+ "AdminName": app_db_username,
134
+ "AdminPassword": app_db_password,
135
+ },
136
+ headers={
137
+ "Content-Type": "application/json",
138
+ "Authorization": bearer_token,
139
+ },
140
+ )
141
+ response.raise_for_status()
142
+
143
+ print(f"Successfully started provisioning {db_name}")
144
+
145
+ status = ""
146
+ while status not in ["available", "backing-up"]:
147
+ if status == "":
148
+ time.sleep(5) # First time sleep 5 sec
149
+ else:
150
+ time.sleep(30) # Otherwise, sleep 30 sec
151
+
152
+ user_db_info = get_user_db_info(credentials, db_name)
153
+ status = user_db_info.Status
154
+ print(
155
+ f"[bold blue]Waiting for cloud database to finish provisioning. Status:[/bold blue] [yellow]{status}[/yellow]"
156
+ )
157
+
158
+ print("[green]Database successfully provisioned![/green]")
159
+ return 0
160
+
161
+ except requests.exceptions.RequestException as e:
162
+ error_label = f"Failed to create database {db_name}"
163
+ if hasattr(e, "response") and e.response is not None:
164
+ resp = e.response.json()
165
+ if is_cloud_api_error_response(resp):
166
+ handle_api_errors(error_label, e)
167
+ else:
168
+ dbos_logger.error(f"{error_label}: {str(e)}")
169
+ return 1
170
+
171
+
172
+ def choose_database(credentials: DBOSCloudCredentials) -> Optional[UserDBInstance]:
173
+ # List existing database instances
174
+ user_dbs: List[UserDBInstance] = []
175
+ bearer_token = f"Bearer {credentials.token}"
176
+
177
+ try:
178
+ response = requests.get(
179
+ f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases",
180
+ headers={
181
+ "Content-Type": "application/json",
182
+ "Authorization": bearer_token,
183
+ },
184
+ )
185
+ response.raise_for_status()
186
+ data = response.json()
187
+ user_dbs = [UserDBInstance(**db) for db in data]
188
+
189
+ except requests.exceptions.RequestException as e:
190
+ error_label = "Failed to list databases"
191
+ if hasattr(e, "response") and e.response is not None:
192
+ resp = e.response.json()
193
+ if is_cloud_api_error_response(resp):
194
+ handle_api_errors(error_label, e)
195
+ else:
196
+ dbos_logger.error(f"{error_label}: {str(e)}")
197
+ return None
198
+
199
+ if not user_dbs:
200
+ # If not, prompt the user to provision one
201
+ print("Provisioning a cloud Postgres database server")
202
+ user_db_name = f"{credentials.userName}-db-server"
203
+
204
+ # Use a default user name and auto generated password
205
+ app_db_username = "dbos_user"
206
+ app_db_password = base64.b64encode(str(random.random()).encode()).decode()
207
+ res = create_user_db(
208
+ credentials, user_db_name, app_db_username, app_db_password
209
+ )
210
+ if res != 0:
211
+ return None
212
+ elif len(user_dbs) > 1:
213
+ # If there is more than one database instance, prompt the user to select one
214
+ choices = [db.PostgresInstanceName for db in user_dbs]
215
+ print("Choose a database instance for this app:")
216
+ for i, entry in enumerate(choices, 1):
217
+ print(f"{i}. {entry}")
218
+ while True:
219
+ try:
220
+ choice = int(input("Enter number: ")) - 1
221
+ if 0 <= choice < len(choices):
222
+ user_db_name = choices[choice]
223
+ break
224
+ except ValueError:
225
+ continue
226
+ print("Invalid choice, please try again")
227
+ else:
228
+ # Use the only available database server
229
+ user_db_name = user_dbs[0].PostgresInstanceName
230
+ print(f"[green]Using database instance:[/green] {user_db_name}")
231
+
232
+ info = get_user_db_info(credentials, user_db_name)
233
+
234
+ if not info.IsLinked:
235
+ create_user_role(credentials, user_db_name)
236
+
237
+ return info
dbos/_db_wizard.py ADDED
@@ -0,0 +1,167 @@
1
+ import time
2
+ from typing import TYPE_CHECKING, Optional
3
+
4
+ import docker # type: ignore
5
+ import typer
6
+ import yaml
7
+ from rich import print
8
+ from sqlalchemy import URL, create_engine, text
9
+
10
+ if TYPE_CHECKING:
11
+ from ._dbos_config import ConfigFile
12
+
13
+ from ._cloudutils.cloudutils import get_cloud_credentials
14
+ from ._cloudutils.databases import choose_database, get_user_db_credentials
15
+ from ._error import DBOSInitializationError
16
+ from ._logger import dbos_logger
17
+
18
+
19
+ def db_connect(config: "ConfigFile", config_file_path: str) -> "ConfigFile":
20
+ # 1. Check the connectivity to the database. Return if successful. If cannot connect, continue to the following steps.
21
+ db_connection_error = _check_db_connectivity(config)
22
+ if db_connection_error is None:
23
+ return config
24
+
25
+ # 2. If the error is due to password authentication or the configuration is non-default, surface the error and exit.
26
+ if "password authentication failed" in str(db_connection_error) or "28P01" in str(
27
+ db_connection_error
28
+ ):
29
+ raise DBOSInitializationError(
30
+ f"Could not connect to Postgres: password authentication failed: {db_connection_error}"
31
+ )
32
+ db_config = config["database"]
33
+ if (
34
+ db_config["hostname"] != "localhost"
35
+ or db_config["port"] != 5432
36
+ or db_config["username"] != "postgres"
37
+ ):
38
+ raise DBOSInitializationError(
39
+ f"Could not connect to the database. Exception: {db_connection_error}"
40
+ )
41
+ print("[yellow]Postgres not detected locally[/yellow]")
42
+
43
+ # 3. If the database config is the default one, check if the user has Docker properly installed.
44
+ print("Attempting to start Postgres via Docker")
45
+ has_docker = _check_docker_installed()
46
+
47
+ # 4. If Docker is installed, prompt the user to start a local Docker based Postgres, and then set the PGPASSWORD to 'dbos' and try to connect to the database.
48
+ docker_started = False
49
+ if has_docker:
50
+ docker_started = _start_docker_postgres(config)
51
+ else:
52
+ print("[yellow]Docker not detected locally[/yellow]")
53
+
54
+ # 5. If no Docker, then prompt the user to log in to DBOS Cloud and provision a DB there. Wait for the remote DB to be ready, and then create a copy of the original config file, and then load the remote connection string to the local config file.
55
+ if not docker_started:
56
+ print("Attempting to connect to Postgres via DBOS Cloud")
57
+ cred = get_cloud_credentials()
58
+ db = choose_database(cred)
59
+ if db is None:
60
+ raise DBOSInitializationError("Error connecting to cloud database")
61
+ config["database"]["hostname"] = db.HostName
62
+ config["database"]["port"] = db.Port
63
+ if db.SupabaseReference is not None:
64
+ config["database"]["username"] = f"postgres.{db.SupabaseReference}"
65
+ supabase_password = typer.prompt(
66
+ "Enter your Supabase database password", hide_input=True
67
+ )
68
+ config["database"]["password"] = supabase_password
69
+ else:
70
+ config["database"]["username"] = db.DatabaseUsername
71
+ db_credentials = get_user_db_credentials(cred, db.PostgresInstanceName)
72
+ config["database"]["password"] = db_credentials.Password
73
+ config["database"]["local_suffix"] = True
74
+
75
+ # Verify these new credentials work
76
+ db_connection_error = _check_db_connectivity(config)
77
+ if db_connection_error is not None:
78
+ raise DBOSInitializationError(
79
+ f"Could not connect to the database. Exception: {db_connection_error}"
80
+ )
81
+
82
+ # 6. Save the config to the config file and return the updated config.
83
+ # TODO: make the config file prettier
84
+ with open(config_file_path, "w") as file:
85
+ file.write(yaml.dump(config))
86
+
87
+ return config
88
+
89
+
90
+ def _start_docker_postgres(config: "ConfigFile") -> bool:
91
+ print("Starting a Postgres Docker container...")
92
+ config["database"]["password"] = "dbos"
93
+ client = docker.from_env()
94
+ pg_data = "/var/lib/postgresql/data"
95
+ container_name = "dbos-db"
96
+ client.containers.run(
97
+ image="pgvector/pgvector:pg16",
98
+ detach=True,
99
+ environment={
100
+ "POSTGRES_PASSWORD": config["database"]["password"],
101
+ "PGDATA": pg_data,
102
+ },
103
+ volumes={pg_data: {"bind": pg_data, "mode": "rw"}},
104
+ ports={"5432/tcp": config["database"]["port"]},
105
+ name=container_name,
106
+ remove=True,
107
+ )
108
+
109
+ container = client.containers.get(container_name)
110
+ attempts = 30
111
+ while attempts > 0:
112
+ if attempts % 5 == 0:
113
+ print("Waiting for Postgres Docker container to start...")
114
+ try:
115
+ res = container.exec_run("psql -U postgres -c 'SELECT 1;'")
116
+ if res.exit_code != 0:
117
+ attempts -= 1
118
+ time.sleep(1)
119
+ continue
120
+ print("[green]Postgres Docker container started successfully![/green]")
121
+ break
122
+ except Exception as e:
123
+ attempts -= 1
124
+ time.sleep(1)
125
+
126
+ if attempts == 0:
127
+ print("[yellow]Failed to start Postgres Docker container.[/yellow]")
128
+ return False
129
+
130
+ return True
131
+
132
+
133
+ def _check_docker_installed() -> bool:
134
+ # Check if Docker is installed
135
+ try:
136
+ client = docker.from_env()
137
+ client.ping()
138
+ except Exception:
139
+ return False
140
+ return True
141
+
142
+
143
+ def _check_db_connectivity(config: "ConfigFile") -> Optional[Exception]:
144
+ postgres_db_url = URL.create(
145
+ "postgresql+psycopg",
146
+ username=config["database"]["username"],
147
+ password=config["database"]["password"],
148
+ host=config["database"]["hostname"],
149
+ port=config["database"]["port"],
150
+ database="postgres",
151
+ query={"connect_timeout": "2"},
152
+ )
153
+ postgres_db_engine = create_engine(postgres_db_url)
154
+ try:
155
+ with postgres_db_engine.connect() as conn:
156
+ val = conn.execute(text("SELECT 1")).scalar()
157
+ if val != 1:
158
+ dbos_logger.error(
159
+ f"Unexpected value returned from database: expected 1, received {val}"
160
+ )
161
+ return Exception()
162
+ except Exception as e:
163
+ return e
164
+ finally:
165
+ postgres_db_engine.dispose()
166
+
167
+ return None
dbos/_dbos.py CHANGED
@@ -76,15 +76,14 @@ else:
76
76
  from ._admin_server import AdminServer
77
77
  from ._app_db import ApplicationDatabase
78
78
  from ._context import (
79
- DBOSContext,
80
79
  EnterDBOSStep,
81
80
  TracedAttributes,
82
81
  assert_current_dbos_context,
83
82
  get_local_dbos_context,
84
83
  )
85
- from ._dbos_config import ConfigFile, _set_env_vars, load_config
84
+ from ._dbos_config import ConfigFile, load_config, set_env_vars
86
85
  from ._error import DBOSException, DBOSNonExistentWorkflowError
87
- from ._logger import add_otlp_to_all_loggers, config_logger, dbos_logger, init_logger
86
+ from ._logger import add_otlp_to_all_loggers, dbos_logger, init_logger
88
87
  from ._sys_db import SystemDatabase
89
88
 
90
89
  # Most DBOS functions are just any callable F, so decorators / wrappers work on F
@@ -264,11 +263,9 @@ class DBOS:
264
263
  return
265
264
 
266
265
  self._initialized: bool = True
267
- init_logger()
268
266
  if config is None:
269
267
  config = load_config()
270
- config_logger(config)
271
- _set_env_vars(config)
268
+ set_env_vars(config)
272
269
  dbos_tracer.config(config)
273
270
  dbos_logger.info("Initializing DBOS")
274
271
  self.config: ConfigFile = config
dbos/_dbos_config.py CHANGED
@@ -8,8 +8,9 @@ import yaml
8
8
  from jsonschema import ValidationError, validate
9
9
  from sqlalchemy import URL
10
10
 
11
+ from ._db_wizard import db_connect
11
12
  from ._error import DBOSInitializationError
12
- from ._logger import dbos_logger
13
+ from ._logger import config_logger, dbos_logger, init_logger
13
14
 
14
15
 
15
16
  class RuntimeConfig(TypedDict, total=False):
@@ -132,6 +133,8 @@ def load_config(config_file_path: str = "dbos-config.yaml") -> ConfigFile:
132
133
 
133
134
  """
134
135
 
136
+ init_logger()
137
+
135
138
  with open(config_file_path, "r") as file:
136
139
  content = file.read()
137
140
  substituted_content = _substitute_env_vars(content)
@@ -176,6 +179,11 @@ def load_config(config_file_path: str = "dbos-config.yaml") -> ConfigFile:
176
179
  if "app_db_name" not in data["database"]:
177
180
  data["database"]["app_db_name"] = _app_name_to_db_name(data["name"])
178
181
 
182
+ config_logger(data)
183
+
184
+ # Check the connectivity to the database and make sure it's properly configured
185
+ data = db_connect(data, config_file_path)
186
+
179
187
  if "local_suffix" in data["database"] and data["database"]["local_suffix"]:
180
188
  data["database"]["app_db_name"] = f"{data['database']['app_db_name']}_local"
181
189
 
@@ -196,7 +204,7 @@ def _app_name_to_db_name(app_name: str) -> str:
196
204
  return name if not name[0].isdigit() else f"_{name}"
197
205
 
198
206
 
199
- def _set_env_vars(config: ConfigFile) -> None:
207
+ def set_env_vars(config: ConfigFile) -> None:
200
208
  for env, value in config.get("env", {}).items():
201
209
  if value is not None:
202
210
  os.environ[env] = str(value)
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "password": {
39
- "type": "string",
39
+ "type": ["string", "null"],
40
40
  "description": "The password to use when connecting to the application database. Developers are strongly encouraged to use environment variable substitution to avoid storing secrets in source."
41
41
  },
42
42
  "connectionTimeoutMillis": {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.17.0a4
3
+ Version: 0.18.0a1
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -19,6 +19,10 @@ Requires-Dist: fastapi[standard]>=0.115.2
19
19
  Requires-Dist: tomlkit>=0.13.2
20
20
  Requires-Dist: psycopg[binary]>=3.1
21
21
  Requires-Dist: fastapi-cli==0.0.5
22
+ Requires-Dist: docker>=7.1.0
23
+ Requires-Dist: cryptography>=43.0.3
24
+ Requires-Dist: rich>=13.9.4
25
+ Requires-Dist: pyjwt>=2.10.1
22
26
  Description-Content-Type: text/markdown
23
27
 
24
28
 
@@ -1,16 +1,20 @@
1
- dbos-0.17.0a4.dist-info/METADATA,sha256=c8YqVga1fsHCq5TQSBnCBfxjkQj1SP1z9kb7E-lSER0,5022
2
- dbos-0.17.0a4.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
3
- dbos-0.17.0a4.dist-info/entry_points.txt,sha256=z6GcVANQV7Uw_82H9Ob2axJX6V3imftyZsljdh-M1HU,54
4
- dbos-0.17.0a4.dist-info/licenses/LICENSE,sha256=VGZit_a5-kdw9WT6fY5jxAWVwGQzgLFyPWrcVVUhVNU,1067
1
+ dbos-0.18.0a1.dist-info/METADATA,sha256=8i67YGwId1DCG0l6uylCoyiSjCz2881pFqnDxh-2JbI,5144
2
+ dbos-0.18.0a1.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
3
+ dbos-0.18.0a1.dist-info/entry_points.txt,sha256=z6GcVANQV7Uw_82H9Ob2axJX6V3imftyZsljdh-M1HU,54
4
+ dbos-0.18.0a1.dist-info/licenses/LICENSE,sha256=VGZit_a5-kdw9WT6fY5jxAWVwGQzgLFyPWrcVVUhVNU,1067
5
5
  dbos/__init__.py,sha256=CxRHBHEthPL4PZoLbZhp3rdm44-KkRTT2-7DkK9d4QQ,724
6
6
  dbos/_admin_server.py,sha256=DOgzVp9kmwiebQqmJB1LcrZnGTxSMbZiGXdenc1wZDg,3163
7
7
  dbos/_app_db.py,sha256=_tv2vmPjjiaikwgxH3mqxgJ4nUUcG2-0uMXKWCqVu1c,5509
8
8
  dbos/_classproperty.py,sha256=f0X-_BySzn3yFDRKB2JpCbLYQ9tLwt1XftfshvY7CBs,626
9
+ dbos/_cloudutils/authentication.py,sha256=V0fCWQN9stCkhbuuxgPTGpvuQcDqfU3KAxPAh01vKW4,5007
10
+ dbos/_cloudutils/cloudutils.py,sha256=5e3CW1deSW-dI5G3QN0XbiVsBhyqT8wu7fuV2f8wtGU,7688
11
+ dbos/_cloudutils/databases.py,sha256=x4187Djsyoa-QaG3Kog8JT2_GERsnqa93LIVanmVUmg,8393
9
12
  dbos/_context.py,sha256=KV3fd3-Rv6EWrYDUdHARxltSlNZGNtQtNSqeQ-gkXE8,18049
10
13
  dbos/_core.py,sha256=NWJFQX5bECBvKlYH9pVmNJgmqFGYPnkHnOGjOlOQ3Ag,33504
11
14
  dbos/_croniter.py,sha256=hbhgfsHBqclUS8VeLnJ9PSE9Z54z6mi4nnrr1aUXn0k,47561
12
- dbos/_dbos.py,sha256=riYx_dkYFzqeVDYpmcA5ABdAYQFhwyDi4AwxIihDNKA,34809
13
- dbos/_dbos_config.py,sha256=f37eccN3JpCA32kRdQ4UsERjhYGcdLWv-N21ijnDZmY,6406
15
+ dbos/_db_wizard.py,sha256=o0OdGEsOjtL-1G6HgbXSigBYSaEN25tfSLrgBw0uh6o,6273
16
+ dbos/_dbos.py,sha256=z12yGw2QHx7BLdxvoI2zJiwDTSes1r48E7qZ7uOn0mw,34723
17
+ dbos/_dbos_config.py,sha256=Hs9L-PJhxeGa2R3nDX75ZFOGLRxA3AiXVf7UoemN9lM,6643
14
18
  dbos/_error.py,sha256=UETk8CoZL-TO2Utn1-E7OSWelhShWmKM-fOlODMR9PE,3893
15
19
  dbos/_fastapi.py,sha256=iyefCZq-ZDKRUjN_rgYQmFmyvWf4gPrSlC6CLbfq4a8,3419
16
20
  dbos/_flask.py,sha256=z1cijbTi5Dpq6kqikPCx1LcR2YHHv2oc41NehOWjw74,2431
@@ -49,7 +53,7 @@ dbos/_templates/hello/migrations/versions/2024_07_31_180642_init.py,sha256=U5thF
49
53
  dbos/_templates/hello/start_postgres_docker.py,sha256=lQVLlYO5YkhGPEgPqwGc7Y8uDKse9HsWv5fynJEFJHM,1681
50
54
  dbos/_tracer.py,sha256=rvBY1RQU6DO7rL7EnaJJxGcmd4tP_PpGqUEE6imZnhY,2518
51
55
  dbos/cli.py,sha256=em1uAxrp5yyg53V7ZpmHFtqD6OJp2cMJkG9vGJPoFTA,10904
52
- dbos/dbos-config.schema.json,sha256=tS7x-bdFbFvpobcs3pIOhwun3yr_ndvTEYOn4BJjTzs,5889
56
+ dbos/dbos-config.schema.json,sha256=00lliu7hGT6NAIZt8UNAn8mEhQ71RGw6Q2CI3nWxULA,5899
53
57
  dbos/py.typed,sha256=QfzXT1Ktfk3Rj84akygc7_42z0lRpCq0Ilh8OXI6Zas,44
54
58
  version/__init__.py,sha256=L4sNxecRuqdtSFdpUGX3TtBi9KL3k7YsZVIvv-fv9-A,1678
55
- dbos-0.17.0a4.dist-info/RECORD,,
59
+ dbos-0.18.0a1.dist-info/RECORD,,