dbos 0.26.0a8__py3-none-any.whl → 0.26.0a9__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.
- dbos/_dbos_config.py +4 -54
- dbos/_docker_pg_helper.py +191 -0
- dbos/cli/cli.py +17 -1
- dbos/dbos-config.schema.json +0 -4
- {dbos-0.26.0a8.dist-info → dbos-0.26.0a9.dist-info}/METADATA +1 -1
- {dbos-0.26.0a8.dist-info → dbos-0.26.0a9.dist-info}/RECORD +9 -12
- dbos/_cloudutils/authentication.py +0 -163
- dbos/_cloudutils/cloudutils.py +0 -254
- dbos/_cloudutils/databases.py +0 -241
- dbos/_db_wizard.py +0 -220
- {dbos-0.26.0a8.dist-info → dbos-0.26.0a9.dist-info}/WHEEL +0 -0
- {dbos-0.26.0a8.dist-info → dbos-0.26.0a9.dist-info}/entry_points.txt +0 -0
- {dbos-0.26.0a8.dist-info → dbos-0.26.0a9.dist-info}/licenses/LICENSE +0 -0
dbos/_dbos_config.py
CHANGED
@@ -15,7 +15,6 @@ from jsonschema import ValidationError, validate
|
|
15
15
|
from rich import print
|
16
16
|
from sqlalchemy import URL, make_url
|
17
17
|
|
18
|
-
from ._db_wizard import db_wizard, load_db_connection
|
19
18
|
from ._error import DBOSInitializationError
|
20
19
|
from ._logger import dbos_logger
|
21
20
|
|
@@ -70,7 +69,6 @@ class DatabaseConfig(TypedDict, total=False):
|
|
70
69
|
sys_db_pool_size: Optional[int]
|
71
70
|
ssl: Optional[bool]
|
72
71
|
ssl_ca: Optional[str]
|
73
|
-
local_suffix: Optional[bool]
|
74
72
|
migrate: Optional[List[str]]
|
75
73
|
rollback: Optional[List[str]]
|
76
74
|
|
@@ -288,7 +286,6 @@ def load_config(
|
|
288
286
|
config_file_path: str = DBOS_CONFIG_PATH,
|
289
287
|
*,
|
290
288
|
run_process_config: bool = True,
|
291
|
-
use_db_wizard: bool = True,
|
292
289
|
silent: bool = False,
|
293
290
|
) -> ConfigFile:
|
294
291
|
"""
|
@@ -339,13 +336,12 @@ def load_config(
|
|
339
336
|
|
340
337
|
data = cast(ConfigFile, data)
|
341
338
|
if run_process_config:
|
342
|
-
data = process_config(data=data,
|
339
|
+
data = process_config(data=data, silent=silent)
|
343
340
|
return data # type: ignore
|
344
341
|
|
345
342
|
|
346
343
|
def process_config(
|
347
344
|
*,
|
348
|
-
use_db_wizard: bool = True,
|
349
345
|
data: ConfigFile,
|
350
346
|
silent: bool = False,
|
351
347
|
) -> ConfigFile:
|
@@ -372,22 +368,17 @@ def process_config(
|
|
372
368
|
# database_url takes precedence over database config, but we need to preserve rollback and migrate if they exist
|
373
369
|
migrate = data["database"].get("migrate", False)
|
374
370
|
rollback = data["database"].get("rollback", False)
|
375
|
-
local_suffix = data["database"].get("local_suffix", False)
|
376
371
|
if data.get("database_url"):
|
377
372
|
dbconfig = parse_database_url_to_dbconfig(cast(str, data["database_url"]))
|
378
373
|
if migrate:
|
379
374
|
dbconfig["migrate"] = cast(List[str], migrate)
|
380
375
|
if rollback:
|
381
376
|
dbconfig["rollback"] = cast(List[str], rollback)
|
382
|
-
if local_suffix:
|
383
|
-
dbconfig["local_suffix"] = cast(bool, local_suffix)
|
384
377
|
data["database"] = dbconfig
|
385
378
|
|
386
379
|
if "app_db_name" not in data["database"] or not (data["database"]["app_db_name"]):
|
387
380
|
data["database"]["app_db_name"] = _app_name_to_db_name(data["name"])
|
388
381
|
|
389
|
-
# Load the DB connection file. Use its values for missing connection parameters. Use defaults otherwise.
|
390
|
-
db_connection = load_db_connection()
|
391
382
|
connection_passed_in = data["database"].get("hostname", None) is not None
|
392
383
|
|
393
384
|
dbos_dbport: Optional[int] = None
|
@@ -397,49 +388,22 @@ def process_config(
|
|
397
388
|
dbos_dbport = int(dbport_env)
|
398
389
|
except ValueError:
|
399
390
|
pass
|
400
|
-
dbos_dblocalsuffix: Optional[bool] = None
|
401
|
-
dblocalsuffix_env = os.getenv("DBOS_DBLOCALSUFFIX")
|
402
|
-
if dblocalsuffix_env:
|
403
|
-
try:
|
404
|
-
dbos_dblocalsuffix = dblocalsuffix_env.casefold() == "true".casefold()
|
405
|
-
except ValueError:
|
406
|
-
pass
|
407
391
|
|
408
392
|
data["database"]["hostname"] = (
|
409
|
-
os.getenv("DBOS_DBHOST")
|
410
|
-
or data["database"].get("hostname")
|
411
|
-
or db_connection.get("hostname")
|
412
|
-
or "localhost"
|
393
|
+
os.getenv("DBOS_DBHOST") or data["database"].get("hostname") or "localhost"
|
413
394
|
)
|
414
395
|
|
415
|
-
data["database"]["port"] = (
|
416
|
-
dbos_dbport or data["database"].get("port") or db_connection.get("port") or 5432
|
417
|
-
)
|
396
|
+
data["database"]["port"] = dbos_dbport or data["database"].get("port") or 5432
|
418
397
|
data["database"]["username"] = (
|
419
|
-
os.getenv("DBOS_DBUSER")
|
420
|
-
or data["database"].get("username")
|
421
|
-
or db_connection.get("username")
|
422
|
-
or "postgres"
|
398
|
+
os.getenv("DBOS_DBUSER") or data["database"].get("username") or "postgres"
|
423
399
|
)
|
424
400
|
data["database"]["password"] = (
|
425
401
|
os.getenv("DBOS_DBPASSWORD")
|
426
402
|
or data["database"].get("password")
|
427
|
-
or db_connection.get("password")
|
428
403
|
or os.environ.get("PGPASSWORD")
|
429
404
|
or "dbos"
|
430
405
|
)
|
431
406
|
|
432
|
-
local_suffix = False
|
433
|
-
dbcon_local_suffix = db_connection.get("local_suffix")
|
434
|
-
if dbcon_local_suffix is not None:
|
435
|
-
local_suffix = dbcon_local_suffix
|
436
|
-
db_local_suffix = data["database"].get("local_suffix")
|
437
|
-
if db_local_suffix is not None:
|
438
|
-
local_suffix = db_local_suffix
|
439
|
-
if dbos_dblocalsuffix is not None:
|
440
|
-
local_suffix = dbos_dblocalsuffix
|
441
|
-
data["database"]["local_suffix"] = local_suffix
|
442
|
-
|
443
407
|
if not data["database"].get("app_db_pool_size"):
|
444
408
|
data["database"]["app_db_pool_size"] = 20
|
445
409
|
if not data["database"].get("sys_db_pool_size"):
|
@@ -454,10 +418,6 @@ def process_config(
|
|
454
418
|
elif "run_admin_server" not in data["runtimeConfig"]:
|
455
419
|
data["runtimeConfig"]["run_admin_server"] = True
|
456
420
|
|
457
|
-
# Check the connectivity to the database and make sure it's properly configured
|
458
|
-
# Note, never use db wizard if the DBOS is running in debug mode (i.e. DBOS_DEBUG_WORKFLOW_ID env var is set)
|
459
|
-
debugWorkflowId = os.getenv("DBOS_DEBUG_WORKFLOW_ID")
|
460
|
-
|
461
421
|
# Pretty-print where we've loaded database connection information from, respecting the log level
|
462
422
|
if not silent and logs["logLevel"] == "INFO" or logs["logLevel"] == "DEBUG":
|
463
423
|
d = data["database"]
|
@@ -470,21 +430,11 @@ def process_config(
|
|
470
430
|
print(
|
471
431
|
f"[bold blue]Using database connection string: {conn_string}[/bold blue]"
|
472
432
|
)
|
473
|
-
elif db_connection.get("hostname"):
|
474
|
-
print(
|
475
|
-
f"[bold blue]Loading database connection string from .dbos/db_connection: {conn_string}[/bold blue]"
|
476
|
-
)
|
477
433
|
else:
|
478
434
|
print(
|
479
435
|
f"[bold blue]Using default database connection string: {conn_string}[/bold blue]"
|
480
436
|
)
|
481
437
|
|
482
|
-
if use_db_wizard and debugWorkflowId is None:
|
483
|
-
data = db_wizard(data)
|
484
|
-
|
485
|
-
if "local_suffix" in data["database"] and data["database"]["local_suffix"]:
|
486
|
-
data["database"]["app_db_name"] = f"{data['database']['app_db_name']}_local"
|
487
|
-
|
488
438
|
# Return data as ConfigFile type
|
489
439
|
return data
|
490
440
|
|
@@ -0,0 +1,191 @@
|
|
1
|
+
import logging
|
2
|
+
import os
|
3
|
+
import subprocess
|
4
|
+
import time
|
5
|
+
|
6
|
+
import docker
|
7
|
+
import psycopg
|
8
|
+
from docker.errors import APIError, NotFound
|
9
|
+
|
10
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
11
|
+
from typing import Any, Dict, Optional, Tuple
|
12
|
+
|
13
|
+
|
14
|
+
def start_docker_pg() -> None:
|
15
|
+
"""
|
16
|
+
Starts a PostgreSQL database in a Docker container.
|
17
|
+
|
18
|
+
This function checks if Docker is installed, and if so, starts a local PostgreSQL
|
19
|
+
database in a Docker container. It configures the database with default settings
|
20
|
+
and provides connection information upon successful startup.
|
21
|
+
|
22
|
+
The function uses environment variable PGPASSWORD if available, otherwise
|
23
|
+
defaults to 'dbos' as the database password.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
None
|
27
|
+
|
28
|
+
Raises:
|
29
|
+
Exception: If there is an error starting the Docker container or if the
|
30
|
+
PostgreSQL service does not become available within the timeout period.
|
31
|
+
"""
|
32
|
+
|
33
|
+
logging.info("Attempting to create a Docker Postgres container...")
|
34
|
+
has_docker = check_docker_installed()
|
35
|
+
|
36
|
+
pool_config = {
|
37
|
+
"host": "localhost",
|
38
|
+
"port": 5432,
|
39
|
+
"password": os.environ.get("PGPASSWORD", "dbos"),
|
40
|
+
"user": "postgres",
|
41
|
+
"database": "postgres",
|
42
|
+
"connect_timeout": 2,
|
43
|
+
}
|
44
|
+
|
45
|
+
# If Docker is installed, start a local Docker based Postgres
|
46
|
+
if has_docker:
|
47
|
+
start_docker_postgres(pool_config)
|
48
|
+
logging.info(
|
49
|
+
f"Postgres available at postgres://postgres:{pool_config['password']}@{pool_config['host']}:{pool_config['port']}"
|
50
|
+
)
|
51
|
+
else:
|
52
|
+
logging.warning("Docker not detected locally")
|
53
|
+
|
54
|
+
|
55
|
+
def check_db_connectivity(config: Dict[str, Any]) -> Optional[Exception]:
|
56
|
+
conn = None
|
57
|
+
try:
|
58
|
+
conn = psycopg.connect(
|
59
|
+
host=config["host"],
|
60
|
+
port=config["port"],
|
61
|
+
user=config["user"],
|
62
|
+
password=config["password"],
|
63
|
+
dbname=config["database"],
|
64
|
+
connect_timeout=config.get("connect_timeout", 30),
|
65
|
+
)
|
66
|
+
cursor = conn.cursor()
|
67
|
+
cursor.execute("SELECT 1;")
|
68
|
+
cursor.close()
|
69
|
+
return None
|
70
|
+
except Exception as error:
|
71
|
+
return error
|
72
|
+
finally:
|
73
|
+
if conn is not None:
|
74
|
+
conn.close()
|
75
|
+
|
76
|
+
|
77
|
+
def exec_sync(cmd: str) -> Tuple[str, str]:
|
78
|
+
result = subprocess.run(cmd, shell=True, text=True, capture_output=True, check=True)
|
79
|
+
return result.stdout, result.stderr
|
80
|
+
|
81
|
+
|
82
|
+
def start_docker_postgres(pool_config: Dict[str, Any]) -> bool:
|
83
|
+
logging.info("Starting a Postgres Docker container...")
|
84
|
+
container_name = "dbos-db"
|
85
|
+
pg_data = "/var/lib/postgresql/data"
|
86
|
+
|
87
|
+
try:
|
88
|
+
client = docker.from_env()
|
89
|
+
|
90
|
+
# Check if the container already exists
|
91
|
+
try:
|
92
|
+
container = client.containers.get(container_name)
|
93
|
+
if container.status == "running":
|
94
|
+
logging.info(f"Container '{container_name}' is already running.")
|
95
|
+
return True
|
96
|
+
elif container.status == "exited":
|
97
|
+
container.start()
|
98
|
+
logging.info(
|
99
|
+
f"Container '{container_name}' was stopped and has been restarted."
|
100
|
+
)
|
101
|
+
return True
|
102
|
+
except NotFound:
|
103
|
+
# Container doesn't exist, proceed with creation
|
104
|
+
pass
|
105
|
+
|
106
|
+
# Create and start the container
|
107
|
+
container = client.containers.run(
|
108
|
+
image="pgvector/pgvector:pg16",
|
109
|
+
name=container_name,
|
110
|
+
detach=True,
|
111
|
+
environment={
|
112
|
+
"POSTGRES_PASSWORD": pool_config["password"],
|
113
|
+
"PGDATA": pg_data,
|
114
|
+
},
|
115
|
+
ports={"5432/tcp": pool_config["port"]},
|
116
|
+
volumes={pg_data: {"bind": pg_data, "mode": "rw"}},
|
117
|
+
remove=True, # Equivalent to --rm
|
118
|
+
)
|
119
|
+
|
120
|
+
logging.info(f"Created container: {container.id}")
|
121
|
+
|
122
|
+
except APIError as e:
|
123
|
+
raise Exception(f"Docker API error: {str(e)}")
|
124
|
+
|
125
|
+
# Wait for PostgreSQL to be ready
|
126
|
+
attempts = 30
|
127
|
+
while attempts > 0:
|
128
|
+
if attempts % 5 == 0:
|
129
|
+
logging.info("Waiting for Postgres Docker container to start...")
|
130
|
+
|
131
|
+
if check_db_connectivity(pool_config) is None:
|
132
|
+
return True
|
133
|
+
|
134
|
+
attempts -= 1
|
135
|
+
time.sleep(1)
|
136
|
+
|
137
|
+
raise Exception(
|
138
|
+
f"Failed to start Docker container: Container {container_name} did not start in time."
|
139
|
+
)
|
140
|
+
|
141
|
+
|
142
|
+
def check_docker_installed() -> bool:
|
143
|
+
"""
|
144
|
+
Check if Docker is installed and running using the docker library.
|
145
|
+
|
146
|
+
Returns:
|
147
|
+
bool: True if Docker is installed and running, False otherwise.
|
148
|
+
"""
|
149
|
+
try:
|
150
|
+
client = docker.from_env()
|
151
|
+
client.ping() # type: ignore
|
152
|
+
return True
|
153
|
+
except Exception:
|
154
|
+
return False
|
155
|
+
|
156
|
+
|
157
|
+
def stop_docker_pg() -> None:
|
158
|
+
"""
|
159
|
+
Stops the Docker Postgres container.
|
160
|
+
|
161
|
+
Returns:
|
162
|
+
bool: True if the container was successfully stopped, False if it wasn't running
|
163
|
+
|
164
|
+
Raises:
|
165
|
+
Exception: If there was an error stopping the container
|
166
|
+
"""
|
167
|
+
logger = logging.getLogger()
|
168
|
+
container_name = "dbos-db"
|
169
|
+
try:
|
170
|
+
logger.info(f"Stopping Docker Postgres container {container_name}...")
|
171
|
+
|
172
|
+
client = docker.from_env()
|
173
|
+
|
174
|
+
try:
|
175
|
+
container = client.containers.get(container_name)
|
176
|
+
|
177
|
+
if container.status == "running":
|
178
|
+
container.stop()
|
179
|
+
logger.info(
|
180
|
+
f"Successfully stopped Docker Postgres container {container_name}."
|
181
|
+
)
|
182
|
+
else:
|
183
|
+
logger.info(f"Container {container_name} exists but is not running.")
|
184
|
+
|
185
|
+
except docker.errors.NotFound:
|
186
|
+
logger.info(f"Container {container_name} does not exist.")
|
187
|
+
|
188
|
+
except Exception as error:
|
189
|
+
error_message = str(error)
|
190
|
+
logger.error(f"Failed to stop Docker Postgres container: {error_message}")
|
191
|
+
raise
|
dbos/cli/cli.py
CHANGED
@@ -20,6 +20,7 @@ from dbos._debug import debug_workflow, parse_start_command
|
|
20
20
|
from .. import load_config
|
21
21
|
from .._app_db import ApplicationDatabase
|
22
22
|
from .._dbos_config import _is_valid_app_name
|
23
|
+
from .._docker_pg_helper import start_docker_pg, stop_docker_pg
|
23
24
|
from .._sys_db import SystemDatabase, reset_system_database
|
24
25
|
from .._workflow_commands import (
|
25
26
|
get_workflow,
|
@@ -37,6 +38,21 @@ queue = typer.Typer()
|
|
37
38
|
app.add_typer(workflow, name="workflow", help="Manage DBOS workflows")
|
38
39
|
workflow.add_typer(queue, name="queue", help="Manage enqueued workflows")
|
39
40
|
|
41
|
+
postgres = typer.Typer()
|
42
|
+
app.add_typer(
|
43
|
+
postgres, name="postgres", help="Manage local Postgres database with Docker"
|
44
|
+
)
|
45
|
+
|
46
|
+
|
47
|
+
@postgres.command(name="start", help="Start a local Postgres database")
|
48
|
+
def pg_start() -> None:
|
49
|
+
start_docker_pg()
|
50
|
+
|
51
|
+
|
52
|
+
@postgres.command(name="stop", help="Stop the local Postgres database")
|
53
|
+
def pg_stop() -> None:
|
54
|
+
stop_docker_pg()
|
55
|
+
|
40
56
|
|
41
57
|
def _on_windows() -> bool:
|
42
58
|
return platform.system() == "Windows"
|
@@ -246,7 +262,7 @@ def reset(
|
|
246
262
|
def debug(
|
247
263
|
workflow_id: Annotated[str, typer.Argument(help="Workflow ID to debug")],
|
248
264
|
) -> None:
|
249
|
-
config = load_config(silent=True
|
265
|
+
config = load_config(silent=True)
|
250
266
|
start = config["runtimeConfig"]["start"]
|
251
267
|
if not start:
|
252
268
|
typer.echo("No start commands found in 'dbos-config.yaml'")
|
dbos/dbos-config.schema.json
CHANGED
@@ -62,10 +62,6 @@
|
|
62
62
|
"type": "string",
|
63
63
|
"description": "If using SSL/TLS to securely connect to a database, path to an SSL root certificate file"
|
64
64
|
},
|
65
|
-
"local_suffix": {
|
66
|
-
"type": "boolean",
|
67
|
-
"description": "Whether to suffix app_db_name with '_local'. Set to true when doing local development using a DBOS Cloud database."
|
68
|
-
},
|
69
65
|
"app_db_client": {
|
70
66
|
"type": "string",
|
71
67
|
"description": "Specify the database client to use to connect to the application database",
|
@@ -1,25 +1,22 @@
|
|
1
|
-
dbos-0.26.
|
2
|
-
dbos-0.26.
|
3
|
-
dbos-0.26.
|
4
|
-
dbos-0.26.
|
1
|
+
dbos-0.26.0a9.dist-info/METADATA,sha256=Olpmk9xnLEbfqKPtj7_cLfCShqh_RFowiykc6KtBTNQ,5553
|
2
|
+
dbos-0.26.0a9.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
|
3
|
+
dbos-0.26.0a9.dist-info/entry_points.txt,sha256=_QOQ3tVfEjtjBlr1jS4sHqHya9lI2aIEIWkz8dqYp14,58
|
4
|
+
dbos-0.26.0a9.dist-info/licenses/LICENSE,sha256=VGZit_a5-kdw9WT6fY5jxAWVwGQzgLFyPWrcVVUhVNU,1067
|
5
5
|
dbos/__init__.py,sha256=3NQfGlBiiUSM_v88STdVP3rNZvGkUL_9WbSotKb8Voo,873
|
6
6
|
dbos/__main__.py,sha256=G7Exn-MhGrVJVDbgNlpzhfh8WMX_72t3_oJaFT9Lmt8,653
|
7
7
|
dbos/_admin_server.py,sha256=vxPG_YJ6lYrkfPCSp42FiATVLBOij7Fm52Yngg5Z_tE,7027
|
8
8
|
dbos/_app_db.py,sha256=IwnNlHEQYp2bl5BM66vVPFa40h8DOtvRgUWTJ1dz20A,8963
|
9
9
|
dbos/_classproperty.py,sha256=f0X-_BySzn3yFDRKB2JpCbLYQ9tLwt1XftfshvY7CBs,626
|
10
10
|
dbos/_client.py,sha256=fzW_Gagh-oyWyDYtREcQDBesoVl_LsEoMeJAsn5-C5s,7262
|
11
|
-
dbos/_cloudutils/authentication.py,sha256=V0fCWQN9stCkhbuuxgPTGpvuQcDqfU3KAxPAh01vKW4,5007
|
12
|
-
dbos/_cloudutils/cloudutils.py,sha256=YC7jGsIopT0KveLsqbRpQk2KlRBk-nIRC_UCgep4f3o,7797
|
13
|
-
dbos/_cloudutils/databases.py,sha256=_shqaqSvhY4n2ScgQ8IP5PDZvzvcx3YBKV8fj-cxhSY,8543
|
14
11
|
dbos/_conductor/conductor.py,sha256=HYzVL29IMMrs2Mnms_7cHJynCnmmEN5SDQOMjzn3UoU,16840
|
15
12
|
dbos/_conductor/protocol.py,sha256=xN7pmooyF1pqbH1b6WhllU5718P7zSb_b0KCwA6bzcs,6716
|
16
13
|
dbos/_context.py,sha256=I8sLkdKTTkZEz7wG-MjynaQB6XEF2bLXuwNksiauP7w,19430
|
17
14
|
dbos/_core.py,sha256=tjBGVbSgOn59lR29gcYi5f6fcKNKQM5EP1QXrQGUkXA,45426
|
18
15
|
dbos/_croniter.py,sha256=XHAyUyibs_59sJQfSNWkP7rqQY6_XrlfuuCxk4jYqek,47559
|
19
|
-
dbos/_db_wizard.py,sha256=VnMa6OL87Lc-XPDD1RnXp8NjsJE8YgiQLj3wtWAXp-8,8252
|
20
16
|
dbos/_dbos.py,sha256=TOLi95Aca50huyOAWl9H5fii4nMYaGwN-zQ8GlLWdOg,45569
|
21
|
-
dbos/_dbos_config.py,sha256=
|
17
|
+
dbos/_dbos_config.py,sha256=m05IFjM0jSwZBsnFMF_4qP2JkjVFc0gqyM2tnotXq20,20636
|
22
18
|
dbos/_debug.py,sha256=MNlQVZ6TscGCRQeEEL0VE8Uignvr6dPeDDDefS3xgIE,1823
|
19
|
+
dbos/_docker_pg_helper.py,sha256=9OGbuavRA_cwE-uPiLZJSdpbQu-6PPgl9clQZB2zT_U,5852
|
23
20
|
dbos/_error.py,sha256=HtdV6Qy7qRyGD57wxLwE7YT0WdYtlx5ZLEe_Kv_gC-U,5953
|
24
21
|
dbos/_fastapi.py,sha256=PhaKftbApHnjtYEOw0EYna_3K0cmz__J9of7mRJWzu4,3704
|
25
22
|
dbos/_flask.py,sha256=DZKUZR5-xOzPI7tYZ53r2PvvHVoAb8SYwLzMVFsVfjI,2608
|
@@ -63,8 +60,8 @@ dbos/_utils.py,sha256=nFRUHzVjXG5AusF85AlYHikj63Tzi-kQm992ihsrAxA,201
|
|
63
60
|
dbos/_workflow_commands.py,sha256=Tf7_hZQoPgP90KHQjMNlBggCNrLLCNRJxHtAJLvarc4,6153
|
64
61
|
dbos/cli/_github_init.py,sha256=Y_bDF9gfO2jB1id4FV5h1oIxEJRWyqVjhb7bNEa5nQ0,3224
|
65
62
|
dbos/cli/_template_init.py,sha256=-WW3kbq0W_Tq4WbMqb1UGJG3xvJb3woEY5VspG95Srk,2857
|
66
|
-
dbos/cli/cli.py,sha256=
|
67
|
-
dbos/dbos-config.schema.json,sha256=
|
63
|
+
dbos/cli/cli.py,sha256=Lb_RYmXoT5KH0xDbwaYpROE4c-svZ0eCq2Kxg7cAxTw,16537
|
64
|
+
dbos/dbos-config.schema.json,sha256=i7jcxXqByKq0Jzv3nAUavONtj03vTwj6vWP4ylmBr8o,5694
|
68
65
|
dbos/py.typed,sha256=QfzXT1Ktfk3Rj84akygc7_42z0lRpCq0Ilh8OXI6Zas,44
|
69
66
|
version/__init__.py,sha256=L4sNxecRuqdtSFdpUGX3TtBi9KL3k7YsZVIvv-fv9-A,1678
|
70
|
-
dbos-0.26.
|
67
|
+
dbos-0.26.0a9.dist-info/RECORD,,
|
@@ -1,163 +0,0 @@
|
|
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
|
-
)
|
dbos/_cloudutils/cloudutils.py
DELETED
@@ -1,254 +0,0 @@
|
|
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
|
-
Organization: str
|
33
|
-
|
34
|
-
def __init__(self, **kwargs: Any) -> None:
|
35
|
-
self.Name = kwargs.get("Name", "")
|
36
|
-
self.Organization = kwargs.get("Organization", "")
|
37
|
-
|
38
|
-
|
39
|
-
class AppLanguages(Enum):
|
40
|
-
Node = "node"
|
41
|
-
Python = "python"
|
42
|
-
|
43
|
-
|
44
|
-
dbos_config_file_path = "dbos-config.yaml"
|
45
|
-
DBOS_CLOUD_HOST = os.getenv("DBOS_DOMAIN", "cloud.dbos.dev")
|
46
|
-
dbos_env_path = ".dbos"
|
47
|
-
|
48
|
-
|
49
|
-
def is_token_expired(token: str) -> bool:
|
50
|
-
try:
|
51
|
-
decoded = jwt.decode(token, options={"verify_signature": False})
|
52
|
-
exp: int = decoded.get("exp")
|
53
|
-
if not exp:
|
54
|
-
return False
|
55
|
-
return time.time() >= exp
|
56
|
-
except Exception:
|
57
|
-
return True
|
58
|
-
|
59
|
-
|
60
|
-
def credentials_exist() -> bool:
|
61
|
-
return os.path.exists(os.path.join(dbos_env_path, "credentials"))
|
62
|
-
|
63
|
-
|
64
|
-
def delete_credentials() -> None:
|
65
|
-
credentials_path = os.path.join(dbos_env_path, "credentials")
|
66
|
-
if os.path.exists(credentials_path):
|
67
|
-
os.unlink(credentials_path)
|
68
|
-
|
69
|
-
|
70
|
-
def write_credentials(credentials: DBOSCloudCredentials) -> None:
|
71
|
-
os.makedirs(dbos_env_path, exist_ok=True)
|
72
|
-
with open(os.path.join(dbos_env_path, "credentials"), "w", encoding="utf-8") as f:
|
73
|
-
json.dump(credentials.__dict__, f)
|
74
|
-
|
75
|
-
|
76
|
-
def check_read_file(path: str, encoding: str = "utf-8") -> Union[str, bytes]:
|
77
|
-
# Check if file exists and is a file
|
78
|
-
if not os.path.exists(path):
|
79
|
-
raise FileNotFoundError(f"File {path} does not exist")
|
80
|
-
if not os.path.isfile(path):
|
81
|
-
raise IsADirectoryError(f"Path {path} is not a file")
|
82
|
-
|
83
|
-
# Read file content
|
84
|
-
with open(path, encoding=encoding) as f:
|
85
|
-
return f.read()
|
86
|
-
|
87
|
-
|
88
|
-
@dataclass
|
89
|
-
class CloudAPIErrorResponse:
|
90
|
-
message: str
|
91
|
-
status_code: int
|
92
|
-
request_id: str
|
93
|
-
detailed_error: Optional[str] = None
|
94
|
-
|
95
|
-
|
96
|
-
def is_cloud_api_error_response(obj: Any) -> bool:
|
97
|
-
return (
|
98
|
-
isinstance(obj, dict)
|
99
|
-
and "message" in obj
|
100
|
-
and isinstance(obj["message"], str)
|
101
|
-
and "statusCode" in obj
|
102
|
-
and isinstance(obj["statusCode"], int)
|
103
|
-
and "requestID" in obj
|
104
|
-
and isinstance(obj["requestID"], str)
|
105
|
-
)
|
106
|
-
|
107
|
-
|
108
|
-
def handle_api_errors(label: str, e: requests.exceptions.RequestException) -> None:
|
109
|
-
if hasattr(e, "response") and e.response is not None:
|
110
|
-
resp = e.response.json()
|
111
|
-
if is_cloud_api_error_response(resp):
|
112
|
-
message = f"[{resp['requestID']}] {label}: {resp['message']}."
|
113
|
-
dbos_logger.error(message)
|
114
|
-
raise DBOSInitializationError(message)
|
115
|
-
|
116
|
-
|
117
|
-
def is_valid_username(value: str) -> Union[bool, str]:
|
118
|
-
if len(value) < 3 or len(value) > 30:
|
119
|
-
return "Username must be 3~30 characters long"
|
120
|
-
if not re.match("^[a-z0-9_]+$", value):
|
121
|
-
return "Username must contain only lowercase letters, numbers, and underscores."
|
122
|
-
return True
|
123
|
-
|
124
|
-
|
125
|
-
def check_user_profile(credentials: DBOSCloudCredentials) -> bool:
|
126
|
-
bearer_token = f"Bearer {credentials.token}"
|
127
|
-
try:
|
128
|
-
response = requests.get(
|
129
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/user/profile",
|
130
|
-
headers={
|
131
|
-
"Content-Type": "application/json",
|
132
|
-
"Authorization": bearer_token,
|
133
|
-
},
|
134
|
-
)
|
135
|
-
response.raise_for_status()
|
136
|
-
profile = UserProfile(**response.json())
|
137
|
-
credentials.userName = profile.Name
|
138
|
-
credentials.organization = profile.Organization
|
139
|
-
return True
|
140
|
-
except requests.exceptions.RequestException as e:
|
141
|
-
error_label = "Failed to login"
|
142
|
-
if hasattr(e, "response") and e.response is not None:
|
143
|
-
resp = e.response.json()
|
144
|
-
if is_cloud_api_error_response(resp):
|
145
|
-
if "user not found in DBOS Cloud" not in resp["message"]:
|
146
|
-
handle_api_errors(error_label, e)
|
147
|
-
exit(1)
|
148
|
-
else:
|
149
|
-
dbos_logger.error(f"{error_label}: {str(e)}")
|
150
|
-
exit(1)
|
151
|
-
return False
|
152
|
-
|
153
|
-
|
154
|
-
def register_user(credentials: DBOSCloudCredentials) -> None:
|
155
|
-
print("Please register for DBOS Cloud")
|
156
|
-
|
157
|
-
user_name = None
|
158
|
-
while not user_name:
|
159
|
-
user_name = typer.prompt("Choose your username")
|
160
|
-
validation_result = is_valid_username(user_name)
|
161
|
-
if validation_result is not True:
|
162
|
-
print(f"[red]Invalid username: {validation_result}[/red]")
|
163
|
-
user_name = None
|
164
|
-
continue
|
165
|
-
|
166
|
-
bearer_token = f"Bearer {credentials.token}"
|
167
|
-
try:
|
168
|
-
# Register user
|
169
|
-
response = requests.put(
|
170
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/user",
|
171
|
-
json={
|
172
|
-
"name": user_name,
|
173
|
-
},
|
174
|
-
headers={
|
175
|
-
"Content-Type": "application/json",
|
176
|
-
"Authorization": bearer_token,
|
177
|
-
},
|
178
|
-
)
|
179
|
-
response.raise_for_status()
|
180
|
-
|
181
|
-
# Get user profile
|
182
|
-
response = requests.get(
|
183
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/user/profile",
|
184
|
-
headers={
|
185
|
-
"Content-Type": "application/json",
|
186
|
-
"Authorization": bearer_token,
|
187
|
-
},
|
188
|
-
)
|
189
|
-
response.raise_for_status()
|
190
|
-
profile = UserProfile(**response.json())
|
191
|
-
credentials.userName = profile.Name
|
192
|
-
credentials.organization = profile.Organization
|
193
|
-
print(f"[green]Successfully registered as {credentials.userName}[/green]")
|
194
|
-
|
195
|
-
except requests.exceptions.RequestException as e:
|
196
|
-
error_label = f"Failed to register user {user_name}"
|
197
|
-
if hasattr(e, "response") and e.response is not None:
|
198
|
-
handle_api_errors(error_label, e)
|
199
|
-
else:
|
200
|
-
dbos_logger.error(f"{error_label}: {str(e)}")
|
201
|
-
exit(1)
|
202
|
-
|
203
|
-
|
204
|
-
def check_credentials() -> DBOSCloudCredentials:
|
205
|
-
empty_credentials = DBOSCloudCredentials(token="", userName="", organization="")
|
206
|
-
|
207
|
-
if not credentials_exist():
|
208
|
-
return empty_credentials
|
209
|
-
|
210
|
-
try:
|
211
|
-
with open(os.path.join(dbos_env_path, "credentials"), "r") as f:
|
212
|
-
cred_data = json.load(f)
|
213
|
-
credentials = DBOSCloudCredentials(**cred_data)
|
214
|
-
|
215
|
-
# Trim trailing /r /n
|
216
|
-
credentials.token = credentials.token.strip()
|
217
|
-
|
218
|
-
if is_token_expired(credentials.token):
|
219
|
-
print("Credentials expired. Logging in again...")
|
220
|
-
delete_credentials()
|
221
|
-
return empty_credentials
|
222
|
-
|
223
|
-
return credentials
|
224
|
-
except Exception as e:
|
225
|
-
dbos_logger.error(f"Error loading credentials: {str(e)}")
|
226
|
-
return empty_credentials
|
227
|
-
|
228
|
-
|
229
|
-
def get_cloud_credentials() -> DBOSCloudCredentials:
|
230
|
-
# Check if credentials exist and are not expired
|
231
|
-
credentials = check_credentials()
|
232
|
-
|
233
|
-
# Log in the user
|
234
|
-
if not credentials.token:
|
235
|
-
auth_response = authenticate()
|
236
|
-
if auth_response is None:
|
237
|
-
dbos_logger.error("Failed to login. Exiting...")
|
238
|
-
exit(1)
|
239
|
-
credentials.token = auth_response.token
|
240
|
-
credentials.refreshToken = auth_response.refresh_token
|
241
|
-
write_credentials(credentials)
|
242
|
-
|
243
|
-
# Check if the user exists in DBOS Cloud
|
244
|
-
user_exists = check_user_profile(credentials)
|
245
|
-
if user_exists:
|
246
|
-
write_credentials(credentials)
|
247
|
-
print(f"[green]Successfully logged in as {credentials.userName}[/green]")
|
248
|
-
return credentials
|
249
|
-
|
250
|
-
# User doesn't exist, register the user in DBOS Cloud
|
251
|
-
register_user(credentials)
|
252
|
-
write_credentials(credentials)
|
253
|
-
|
254
|
-
return credentials
|
dbos/_cloudutils/databases.py
DELETED
@@ -1,241 +0,0 @@
|
|
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
|
-
def __init__(self, **kwargs: Any) -> None:
|
27
|
-
self.RoleName = kwargs.get("RoleName", "")
|
28
|
-
self.Password = kwargs.get("Password", "")
|
29
|
-
|
30
|
-
|
31
|
-
@dataclass
|
32
|
-
class UserDBInstance:
|
33
|
-
PostgresInstanceName: str = ""
|
34
|
-
Status: str = ""
|
35
|
-
HostName: str = ""
|
36
|
-
Port: int = 0
|
37
|
-
DatabaseUsername: str = ""
|
38
|
-
IsLinked: bool = False
|
39
|
-
SupabaseReference: Optional[str] = None
|
40
|
-
|
41
|
-
def __init__(self, **kwargs: Any) -> None:
|
42
|
-
self.PostgresInstanceName = kwargs.get("PostgresInstanceName", "")
|
43
|
-
self.Status = kwargs.get("Status", "")
|
44
|
-
self.HostName = kwargs.get("HostName", "")
|
45
|
-
self.Port = kwargs.get("Port", 0)
|
46
|
-
self.DatabaseUsername = kwargs.get("DatabaseUsername", "")
|
47
|
-
self.IsLinked = kwargs.get("IsLinked", False)
|
48
|
-
self.SupabaseReference = kwargs.get("SupabaseReference", None)
|
49
|
-
|
50
|
-
|
51
|
-
def get_user_db_info(credentials: DBOSCloudCredentials, db_name: str) -> UserDBInstance:
|
52
|
-
bearer_token = f"Bearer {credentials.token}"
|
53
|
-
|
54
|
-
try:
|
55
|
-
response = requests.get(
|
56
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb/info/{db_name}",
|
57
|
-
headers={
|
58
|
-
"Content-Type": "application/json",
|
59
|
-
"Authorization": bearer_token,
|
60
|
-
},
|
61
|
-
)
|
62
|
-
response.raise_for_status()
|
63
|
-
data = response.json()
|
64
|
-
return UserDBInstance(**data)
|
65
|
-
except requests.exceptions.RequestException as e:
|
66
|
-
error_label = f"Failed to get status of database {db_name}"
|
67
|
-
if hasattr(e, "response") and e.response is not None:
|
68
|
-
resp = e.response.json()
|
69
|
-
if is_cloud_api_error_response(resp):
|
70
|
-
handle_api_errors(error_label, e)
|
71
|
-
else:
|
72
|
-
dbos_logger.error(f"{error_label}: {str(e)}")
|
73
|
-
raise DBOSInitializationError(f"{error_label}: {str(e)}")
|
74
|
-
|
75
|
-
|
76
|
-
def get_user_db_credentials(
|
77
|
-
credentials: DBOSCloudCredentials, db_name: str
|
78
|
-
) -> UserDBCredentials:
|
79
|
-
bearer_token = f"Bearer {credentials.token}"
|
80
|
-
try:
|
81
|
-
response = requests.get(
|
82
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb/{db_name}/credentials",
|
83
|
-
headers={
|
84
|
-
"Content-Type": "application/json",
|
85
|
-
"Authorization": bearer_token,
|
86
|
-
},
|
87
|
-
)
|
88
|
-
response.raise_for_status()
|
89
|
-
data = response.json()
|
90
|
-
return UserDBCredentials(**data)
|
91
|
-
except requests.exceptions.RequestException as e:
|
92
|
-
error_label = f"Failed to get credentials for database {db_name}"
|
93
|
-
if hasattr(e, "response") and e.response is not None:
|
94
|
-
resp = e.response.json()
|
95
|
-
if is_cloud_api_error_response(resp):
|
96
|
-
handle_api_errors(error_label, e)
|
97
|
-
else:
|
98
|
-
dbos_logger.error(f"{error_label}: {str(e)}")
|
99
|
-
raise DBOSInitializationError(f"{error_label}: {str(e)}")
|
100
|
-
|
101
|
-
|
102
|
-
def create_user_role(credentials: DBOSCloudCredentials, db_name: str) -> None:
|
103
|
-
bearer_token = f"Bearer {credentials.token}"
|
104
|
-
try:
|
105
|
-
response = requests.post(
|
106
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb/{db_name}/createuserdbrole",
|
107
|
-
headers={
|
108
|
-
"Content-Type": "application/json",
|
109
|
-
"Authorization": bearer_token,
|
110
|
-
},
|
111
|
-
)
|
112
|
-
response.raise_for_status()
|
113
|
-
except requests.exceptions.RequestException as e:
|
114
|
-
error_label = f"Failed to create a user role for database {db_name}"
|
115
|
-
if hasattr(e, "response") and e.response is not None:
|
116
|
-
resp = e.response.json()
|
117
|
-
if is_cloud_api_error_response(resp):
|
118
|
-
handle_api_errors(error_label, e)
|
119
|
-
else:
|
120
|
-
dbos_logger.error(f"{error_label}: {str(e)}")
|
121
|
-
raise DBOSInitializationError(f"{error_label}: {str(e)}")
|
122
|
-
|
123
|
-
|
124
|
-
def create_user_db(
|
125
|
-
credentials: DBOSCloudCredentials,
|
126
|
-
db_name: str,
|
127
|
-
app_db_username: str,
|
128
|
-
app_db_password: str,
|
129
|
-
) -> int:
|
130
|
-
bearer_token = f"Bearer {credentials.token}"
|
131
|
-
|
132
|
-
try:
|
133
|
-
response = requests.post(
|
134
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases/userdb",
|
135
|
-
json={
|
136
|
-
"Name": db_name,
|
137
|
-
"AdminName": app_db_username,
|
138
|
-
"AdminPassword": app_db_password,
|
139
|
-
},
|
140
|
-
headers={
|
141
|
-
"Content-Type": "application/json",
|
142
|
-
"Authorization": bearer_token,
|
143
|
-
},
|
144
|
-
)
|
145
|
-
response.raise_for_status()
|
146
|
-
|
147
|
-
print(f"Successfully started provisioning {db_name}")
|
148
|
-
|
149
|
-
status = ""
|
150
|
-
while status not in ["available", "backing-up"]:
|
151
|
-
if status == "":
|
152
|
-
time.sleep(5) # First time sleep 5 sec
|
153
|
-
else:
|
154
|
-
time.sleep(30) # Otherwise, sleep 30 sec
|
155
|
-
|
156
|
-
user_db_info = get_user_db_info(credentials, db_name)
|
157
|
-
status = user_db_info.Status
|
158
|
-
print(
|
159
|
-
f"[bold blue]Waiting for cloud database to finish provisioning. Status:[/bold blue] [yellow]{status}[/yellow]"
|
160
|
-
)
|
161
|
-
|
162
|
-
print("[green]Database successfully provisioned![/green]")
|
163
|
-
return 0
|
164
|
-
|
165
|
-
except requests.exceptions.RequestException as e:
|
166
|
-
error_label = f"Failed to create database {db_name}"
|
167
|
-
if hasattr(e, "response") and e.response is not None:
|
168
|
-
resp = e.response.json()
|
169
|
-
if is_cloud_api_error_response(resp):
|
170
|
-
handle_api_errors(error_label, e)
|
171
|
-
else:
|
172
|
-
dbos_logger.error(f"{error_label}: {str(e)}")
|
173
|
-
return 1
|
174
|
-
|
175
|
-
|
176
|
-
def choose_database(credentials: DBOSCloudCredentials) -> Optional[UserDBInstance]:
|
177
|
-
# List existing database instances
|
178
|
-
user_dbs: List[UserDBInstance] = []
|
179
|
-
bearer_token = f"Bearer {credentials.token}"
|
180
|
-
|
181
|
-
try:
|
182
|
-
response = requests.get(
|
183
|
-
f"https://{DBOS_CLOUD_HOST}/v1alpha1/{credentials.organization}/databases",
|
184
|
-
headers={
|
185
|
-
"Content-Type": "application/json",
|
186
|
-
"Authorization": bearer_token,
|
187
|
-
},
|
188
|
-
)
|
189
|
-
response.raise_for_status()
|
190
|
-
data = response.json()
|
191
|
-
user_dbs = [UserDBInstance(**db) for db in data]
|
192
|
-
|
193
|
-
except requests.exceptions.RequestException as e:
|
194
|
-
error_label = "Failed to list databases"
|
195
|
-
if hasattr(e, "response") and e.response is not None:
|
196
|
-
resp = e.response.json()
|
197
|
-
if is_cloud_api_error_response(resp):
|
198
|
-
handle_api_errors(error_label, e)
|
199
|
-
else:
|
200
|
-
dbos_logger.error(f"{error_label}: {str(e)}")
|
201
|
-
return None
|
202
|
-
|
203
|
-
if not user_dbs:
|
204
|
-
# If not, prompt the user to provision one
|
205
|
-
print("Provisioning a cloud Postgres database server")
|
206
|
-
user_db_name = f"{credentials.userName}-db-server"
|
207
|
-
|
208
|
-
# Use a default user name and auto generated password
|
209
|
-
app_db_username = "dbos_user"
|
210
|
-
app_db_password = base64.b64encode(str(random.random()).encode()).decode()
|
211
|
-
res = create_user_db(
|
212
|
-
credentials, user_db_name, app_db_username, app_db_password
|
213
|
-
)
|
214
|
-
if res != 0:
|
215
|
-
return None
|
216
|
-
elif len(user_dbs) > 1:
|
217
|
-
# If there is more than one database instance, prompt the user to select one
|
218
|
-
choices = [db.PostgresInstanceName for db in user_dbs]
|
219
|
-
print("Choose a database instance for this app:")
|
220
|
-
for i, entry in enumerate(choices, 1):
|
221
|
-
print(f"{i}. {entry}")
|
222
|
-
while True:
|
223
|
-
try:
|
224
|
-
choice = int(input("Enter number: ")) - 1
|
225
|
-
if 0 <= choice < len(choices):
|
226
|
-
user_db_name = choices[choice]
|
227
|
-
break
|
228
|
-
except ValueError:
|
229
|
-
continue
|
230
|
-
print("Invalid choice, please try again")
|
231
|
-
else:
|
232
|
-
# Use the only available database server
|
233
|
-
user_db_name = user_dbs[0].PostgresInstanceName
|
234
|
-
print(f"[green]Using database instance:[/green] {user_db_name}")
|
235
|
-
|
236
|
-
info = get_user_db_info(credentials, user_db_name)
|
237
|
-
|
238
|
-
if not info.IsLinked:
|
239
|
-
create_user_role(credentials, user_db_name)
|
240
|
-
|
241
|
-
return info
|
dbos/_db_wizard.py
DELETED
@@ -1,220 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
import os
|
3
|
-
import time
|
4
|
-
from typing import TYPE_CHECKING, Optional, TypedDict, cast
|
5
|
-
|
6
|
-
import docker # type: ignore
|
7
|
-
import typer
|
8
|
-
import yaml
|
9
|
-
from rich import print
|
10
|
-
from sqlalchemy import URL, create_engine, text
|
11
|
-
|
12
|
-
if TYPE_CHECKING:
|
13
|
-
from ._dbos_config import ConfigFile
|
14
|
-
|
15
|
-
from ._cloudutils.cloudutils import get_cloud_credentials
|
16
|
-
from ._cloudutils.databases import choose_database, get_user_db_credentials
|
17
|
-
from ._error import DBOSInitializationError
|
18
|
-
from ._logger import dbos_logger
|
19
|
-
|
20
|
-
DB_CONNECTION_PATH = os.path.join(".dbos", "db_connection")
|
21
|
-
|
22
|
-
|
23
|
-
class DatabaseConnection(TypedDict):
|
24
|
-
hostname: Optional[str]
|
25
|
-
port: Optional[int]
|
26
|
-
username: Optional[str]
|
27
|
-
password: Optional[str]
|
28
|
-
local_suffix: Optional[bool]
|
29
|
-
|
30
|
-
|
31
|
-
def db_wizard(config: "ConfigFile") -> "ConfigFile":
|
32
|
-
"""Checks database connectivity and helps the user start a database if needed
|
33
|
-
|
34
|
-
First, check connectivity to the database configured in the provided `config` object.
|
35
|
-
If it fails:
|
36
|
-
- Return an error if the connection failed due to incorrect credentials.
|
37
|
-
- Return an error if it detects a non-default configuration.
|
38
|
-
- Otherwise assume the configured database is not running and guide the user through setting it up.
|
39
|
-
|
40
|
-
The wizard will first attempt to start a local Postgres instance using Docker.
|
41
|
-
If Docker is not available, it will prompt the user to connect to a DBOS Cloud database.
|
42
|
-
|
43
|
-
Finally, if a database was configured, its connection details will be saved in the local `.dbos/db_connection` file.
|
44
|
-
"""
|
45
|
-
# 1. Check the connectivity to the database. Return if successful. If cannot connect, continue to the following steps.
|
46
|
-
db_connection_error = _check_db_connectivity(config)
|
47
|
-
if db_connection_error is None:
|
48
|
-
return config
|
49
|
-
|
50
|
-
# 2. If the error is due to password authentication or the configuration is non-default, surface the error and exit.
|
51
|
-
error_str = str(db_connection_error)
|
52
|
-
dbos_logger.debug(f"Error connecting to Postgres: {error_str}")
|
53
|
-
if (
|
54
|
-
"password authentication failed" in error_str
|
55
|
-
or "28P01" in error_str
|
56
|
-
or "no password supplied" in error_str
|
57
|
-
):
|
58
|
-
raise DBOSInitializationError(
|
59
|
-
f"Could not connect to Postgres: password authentication failed: {db_connection_error}"
|
60
|
-
)
|
61
|
-
|
62
|
-
# If the database config is not the default one, surface the error and exit.
|
63
|
-
db_config = config["database"] # FIXME: what if database is not in config?
|
64
|
-
if (
|
65
|
-
db_config["hostname"] != "localhost"
|
66
|
-
or db_config["port"] != 5432
|
67
|
-
or db_config["username"] != "postgres"
|
68
|
-
):
|
69
|
-
raise DBOSInitializationError(
|
70
|
-
f"Could not connect to the database. Exception: {db_connection_error}"
|
71
|
-
)
|
72
|
-
|
73
|
-
print("[yellow]Postgres not detected locally[/yellow]")
|
74
|
-
|
75
|
-
# 3. If the database config is the default one, check if the user has Docker properly installed.
|
76
|
-
print("Attempting to start Postgres via Docker")
|
77
|
-
has_docker = _check_docker_installed()
|
78
|
-
|
79
|
-
# 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.
|
80
|
-
docker_started = False
|
81
|
-
if has_docker:
|
82
|
-
docker_started = _start_docker_postgres(config)
|
83
|
-
else:
|
84
|
-
print("[yellow]Docker not detected locally[/yellow]")
|
85
|
-
|
86
|
-
# 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.
|
87
|
-
if not docker_started:
|
88
|
-
print("Attempting to connect to Postgres via DBOS Cloud")
|
89
|
-
cred = get_cloud_credentials()
|
90
|
-
db = choose_database(cred)
|
91
|
-
if db is None:
|
92
|
-
raise DBOSInitializationError("Error connecting to cloud database")
|
93
|
-
config["database"]["hostname"] = db.HostName
|
94
|
-
config["database"]["port"] = db.Port
|
95
|
-
if db.SupabaseReference is not None:
|
96
|
-
config["database"]["username"] = f"postgres.{db.SupabaseReference}"
|
97
|
-
supabase_password = typer.prompt(
|
98
|
-
"Enter your Supabase database password", hide_input=True
|
99
|
-
)
|
100
|
-
config["database"]["password"] = supabase_password
|
101
|
-
else:
|
102
|
-
config["database"]["username"] = db.DatabaseUsername
|
103
|
-
db_credentials = get_user_db_credentials(cred, db.PostgresInstanceName)
|
104
|
-
config["database"]["password"] = db_credentials.Password
|
105
|
-
config["database"]["local_suffix"] = True
|
106
|
-
|
107
|
-
# Verify these new credentials work
|
108
|
-
db_connection_error = _check_db_connectivity(config)
|
109
|
-
if db_connection_error is not None:
|
110
|
-
raise DBOSInitializationError(
|
111
|
-
f"Could not connect to the database. Exception: {db_connection_error}"
|
112
|
-
)
|
113
|
-
|
114
|
-
# 6. Save the config to the database connection file
|
115
|
-
updated_connection = DatabaseConnection(
|
116
|
-
hostname=config["database"]["hostname"],
|
117
|
-
port=config["database"]["port"],
|
118
|
-
username=config["database"]["username"],
|
119
|
-
password=config["database"]["password"],
|
120
|
-
local_suffix=config["database"]["local_suffix"],
|
121
|
-
)
|
122
|
-
save_db_connection(updated_connection)
|
123
|
-
return config
|
124
|
-
|
125
|
-
|
126
|
-
def _start_docker_postgres(config: "ConfigFile") -> bool:
|
127
|
-
print("Starting a Postgres Docker container...")
|
128
|
-
client = docker.from_env()
|
129
|
-
pg_data = "/var/lib/postgresql/data"
|
130
|
-
container_name = "dbos-db"
|
131
|
-
client.containers.run(
|
132
|
-
image="pgvector/pgvector:pg16",
|
133
|
-
detach=True,
|
134
|
-
environment={
|
135
|
-
"POSTGRES_PASSWORD": config["database"]["password"],
|
136
|
-
"PGDATA": pg_data,
|
137
|
-
},
|
138
|
-
volumes={pg_data: {"bind": pg_data, "mode": "rw"}},
|
139
|
-
ports={"5432/tcp": config["database"]["port"]},
|
140
|
-
name=container_name,
|
141
|
-
remove=True,
|
142
|
-
)
|
143
|
-
|
144
|
-
container = client.containers.get(container_name)
|
145
|
-
attempts = 30
|
146
|
-
while attempts > 0:
|
147
|
-
if attempts % 5 == 0:
|
148
|
-
print("Waiting for Postgres Docker container to start...")
|
149
|
-
try:
|
150
|
-
res = container.exec_run("psql -U postgres -c 'SELECT 1;'")
|
151
|
-
if res.exit_code != 0:
|
152
|
-
attempts -= 1
|
153
|
-
time.sleep(1)
|
154
|
-
continue
|
155
|
-
print("[green]Postgres Docker container started successfully![/green]")
|
156
|
-
break
|
157
|
-
except:
|
158
|
-
attempts -= 1
|
159
|
-
time.sleep(1)
|
160
|
-
|
161
|
-
if attempts == 0:
|
162
|
-
print("[yellow]Failed to start Postgres Docker container.[/yellow]")
|
163
|
-
return False
|
164
|
-
|
165
|
-
return True
|
166
|
-
|
167
|
-
|
168
|
-
def _check_docker_installed() -> bool:
|
169
|
-
# Check if Docker is installed
|
170
|
-
try:
|
171
|
-
client = docker.from_env()
|
172
|
-
client.ping()
|
173
|
-
except Exception:
|
174
|
-
return False
|
175
|
-
return True
|
176
|
-
|
177
|
-
|
178
|
-
def _check_db_connectivity(config: "ConfigFile") -> Optional[Exception]:
|
179
|
-
postgres_db_url = URL.create(
|
180
|
-
"postgresql+psycopg",
|
181
|
-
username=config["database"]["username"],
|
182
|
-
password=config["database"]["password"],
|
183
|
-
host=config["database"]["hostname"],
|
184
|
-
port=config["database"]["port"],
|
185
|
-
database="postgres",
|
186
|
-
query={"connect_timeout": "2"},
|
187
|
-
)
|
188
|
-
postgres_db_engine = create_engine(postgres_db_url)
|
189
|
-
try:
|
190
|
-
with postgres_db_engine.connect() as conn:
|
191
|
-
conn.execute(text("SELECT 1")).scalar()
|
192
|
-
except Exception as e:
|
193
|
-
return e
|
194
|
-
finally:
|
195
|
-
postgres_db_engine.dispose()
|
196
|
-
|
197
|
-
return None
|
198
|
-
|
199
|
-
|
200
|
-
def load_db_connection() -> DatabaseConnection:
|
201
|
-
try:
|
202
|
-
with open(DB_CONNECTION_PATH, "r") as f:
|
203
|
-
data = json.load(f)
|
204
|
-
return DatabaseConnection(
|
205
|
-
hostname=data.get("hostname", None),
|
206
|
-
port=data.get("port", None),
|
207
|
-
username=data.get("username", None),
|
208
|
-
password=data.get("password", None),
|
209
|
-
local_suffix=data.get("local_suffix", None),
|
210
|
-
)
|
211
|
-
except:
|
212
|
-
return DatabaseConnection(
|
213
|
-
hostname=None, port=None, username=None, password=None, local_suffix=None
|
214
|
-
)
|
215
|
-
|
216
|
-
|
217
|
-
def save_db_connection(connection: DatabaseConnection) -> None:
|
218
|
-
os.makedirs(".dbos", exist_ok=True)
|
219
|
-
with open(DB_CONNECTION_PATH, "w") as f:
|
220
|
-
json.dump(connection, f)
|
File without changes
|
File without changes
|
File without changes
|