dagster-postgres 0.29.2__tar.gz → 0.29.4__tar.gz
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.
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/.gitignore +1 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/PKG-INFO +12 -2
- dagster_postgres-0.29.4/dagster_postgres/auth.py +195 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/event_log/event_log.py +18 -7
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/run_storage/run_storage.py +14 -5
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/schedule_storage/schedule_storage.py +17 -6
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/storage.py +20 -7
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/utils.py +51 -6
- dagster_postgres-0.29.4/dagster_postgres/version.py +1 -0
- dagster_postgres-0.29.4/dagster_postgres_tests/test_auth.py +229 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/pyproject.toml +16 -2
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/tox.ini +3 -2
- dagster_postgres-0.29.2/dagster_postgres/version.py +0 -1
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/.coveragerc +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/LICENSE +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/README.md +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/conftest.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/__init__.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/alembic/alembic.ini +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/event_log/__init__.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/py.typed +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/run_storage/__init__.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/schedule_storage/__init__.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/test_fixtures/__init__.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/test_fixtures/docker-compose.yml +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/__init__.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/__init__.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/dagster.yaml +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_10_0_wipe_schedules/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_10_6_add_bulk_actions_table/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_11_0_pre_asset_details/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_11_16_pre_add_mode_column/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_12_0_pre_asset_index_cols/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_13_12_pre_start_time_end_time/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_14_6_instigators_table/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_14_6_post_schema_pre_data_migration/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_14_6_schedule_migration_table/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_6_6/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_7_6_pre_add_pipeline_snapshot/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_7_6_pre_event_log_migration/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_8_0_scheduler_update/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_9_22_pre_asset_partition/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_0_9_22_pre_run_partition/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_1_0_12_pre_add_asset_event_tags_table/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_1_0_17_pre_add_cached_status_data_column/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_1_1_22_pre_primary_key/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_1_8_12_pre_add_backfill_id_column_to_runs_table/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/snapshot_1_9_3_add_run_tags_run_id_idx/postgres/pg_dump.txt +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/test_back_compat.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/conftest.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_daemon_cursor_storage.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_event_log.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_instance.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_run_storage.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_schedule_storage.py +0 -0
- {dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dagster-postgres
|
|
3
|
-
Version: 0.29.
|
|
3
|
+
Version: 0.29.4
|
|
4
4
|
Summary: A Dagster integration for postgres
|
|
5
5
|
Project-URL: Homepage, https://github.com/dagster-io/dagster/tree/master/python_modules/libraries/dagster-postgres
|
|
6
6
|
Author-email: Dagster Labs <hello@dagsterlabs.com>
|
|
@@ -13,6 +13,16 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Requires-Python: <3.15,>=3.10
|
|
16
|
-
Requires-Dist: dagster==1.13.
|
|
16
|
+
Requires-Dist: dagster==1.13.4
|
|
17
17
|
Requires-Dist: psycopg2-binary
|
|
18
|
+
Provides-Extra: aws
|
|
19
|
+
Requires-Dist: boto3>=1.26.0; extra == 'aws'
|
|
20
|
+
Provides-Extra: azure
|
|
21
|
+
Requires-Dist: azure-identity>=1.12.0; extra == 'azure'
|
|
22
|
+
Provides-Extra: gcp
|
|
23
|
+
Requires-Dist: google-auth>=2.0.0; extra == 'gcp'
|
|
18
24
|
Provides-Extra: test
|
|
25
|
+
Provides-Extra: wif
|
|
26
|
+
Requires-Dist: azure-identity>=1.12.0; extra == 'wif'
|
|
27
|
+
Requires-Dist: boto3>=1.26.0; extra == 'wif'
|
|
28
|
+
Requires-Dist: google-auth>=2.0.0; extra == 'wif'
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import sqlalchemy
|
|
8
|
+
from sqlalchemy import event
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PgTokenProvider(abc.ABC):
|
|
14
|
+
"""Abstract base for providers that supply short-lived database access tokens.
|
|
15
|
+
|
|
16
|
+
Subclasses implement ``_fetch_token`` to obtain a fresh token from their
|
|
17
|
+
respective cloud provider. Caching and thread-safety are handled by the
|
|
18
|
+
base class so that callers can simply invoke ``get_token()``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._cached_token: str | None = None
|
|
23
|
+
self._token_expiry: float = 0.0 # epoch seconds
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
@abc.abstractmethod
|
|
27
|
+
def _fetch_token(self) -> tuple[str, float]:
|
|
28
|
+
"""Return ``(token, expiry_epoch_seconds)``.
|
|
29
|
+
|
|
30
|
+
If the exact expiry is unknown, implementations should return
|
|
31
|
+
``time.time() + 3600`` as a conservative default (1 hour).
|
|
32
|
+
"""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def get_token(self) -> str:
|
|
36
|
+
"""Return a valid token, refreshing if expired or about to expire."""
|
|
37
|
+
# Refresh 5 minutes before expiry to avoid races
|
|
38
|
+
with self._lock:
|
|
39
|
+
if self._cached_token is None or time.time() >= (self._token_expiry - 300):
|
|
40
|
+
logger.debug("Refreshing PostgreSQL auth token")
|
|
41
|
+
self._cached_token, self._token_expiry = self._fetch_token()
|
|
42
|
+
return self._cached_token
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AzureWifTokenProvider(PgTokenProvider):
|
|
46
|
+
"""Azure Workload Identity Federation token provider.
|
|
47
|
+
|
|
48
|
+
Uses ``DefaultAzureCredential`` which automatically picks up the
|
|
49
|
+
``AZURE_CLIENT_ID``, ``AZURE_TENANT_ID``, and
|
|
50
|
+
``AZURE_FEDERATED_TOKEN_FILE`` environment variables set by the Azure
|
|
51
|
+
Workload Identity webhook on AKS pods.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
scope: str = "https://ossrdbms-aad.database.windows.net/.default",
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self._scope = scope
|
|
60
|
+
self._credential: Any = None
|
|
61
|
+
|
|
62
|
+
def _fetch_token(self) -> tuple[str, float]:
|
|
63
|
+
if self._credential is None:
|
|
64
|
+
try:
|
|
65
|
+
from azure.identity import DefaultAzureCredential
|
|
66
|
+
except ImportError:
|
|
67
|
+
raise ImportError(
|
|
68
|
+
"azure-identity is required for Azure WIF auth. "
|
|
69
|
+
"Install with: pip install dagster-postgres[azure]"
|
|
70
|
+
)
|
|
71
|
+
self._credential = DefaultAzureCredential()
|
|
72
|
+
token = self._credential.get_token(self._scope)
|
|
73
|
+
return token.token, token.expires_on
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class GcpWifTokenProvider(PgTokenProvider):
|
|
77
|
+
"""GCP Workload Identity Federation token provider.
|
|
78
|
+
|
|
79
|
+
Uses Application Default Credentials (ADC), which automatically picks up
|
|
80
|
+
the projected service account token on GKE pods.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
super().__init__()
|
|
85
|
+
self._credentials: Any = None
|
|
86
|
+
self._request: Any = None
|
|
87
|
+
|
|
88
|
+
def _fetch_token(self) -> tuple[str, float]:
|
|
89
|
+
if self._credentials is None:
|
|
90
|
+
try:
|
|
91
|
+
import google.auth
|
|
92
|
+
import google.auth.transport.requests
|
|
93
|
+
except ImportError:
|
|
94
|
+
raise ImportError(
|
|
95
|
+
"google-auth is required for GCP WIF auth. "
|
|
96
|
+
"Install with: pip install dagster-postgres[gcp]"
|
|
97
|
+
)
|
|
98
|
+
self._credentials, _ = google.auth.default(
|
|
99
|
+
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
100
|
+
)
|
|
101
|
+
self._request = google.auth.transport.requests.Request()
|
|
102
|
+
self._credentials.refresh(self._request)
|
|
103
|
+
expiry = (
|
|
104
|
+
self._credentials.expiry.timestamp()
|
|
105
|
+
if self._credentials.expiry
|
|
106
|
+
else (time.time() + 3600)
|
|
107
|
+
)
|
|
108
|
+
return self._credentials.token, expiry
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AwsWifTokenProvider(PgTokenProvider):
|
|
112
|
+
"""AWS IAM / IRSA token provider for RDS.
|
|
113
|
+
|
|
114
|
+
Uses ``boto3`` to generate an RDS auth token. Requires the hostname,
|
|
115
|
+
port, username, and AWS region from the connection config.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
hostname: str,
|
|
121
|
+
port: int,
|
|
122
|
+
username: str,
|
|
123
|
+
region: str,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__()
|
|
126
|
+
self._hostname = hostname
|
|
127
|
+
self._port = port
|
|
128
|
+
self._username = username
|
|
129
|
+
self._region = region
|
|
130
|
+
self._client: Any = None
|
|
131
|
+
|
|
132
|
+
def _fetch_token(self) -> tuple[str, float]:
|
|
133
|
+
if self._client is None:
|
|
134
|
+
try:
|
|
135
|
+
import boto3
|
|
136
|
+
except ImportError:
|
|
137
|
+
raise ImportError(
|
|
138
|
+
"boto3 is required for AWS WIF auth. "
|
|
139
|
+
"Install with: pip install dagster-postgres[aws]"
|
|
140
|
+
)
|
|
141
|
+
self._client = boto3.client("rds", region_name=self._region)
|
|
142
|
+
token = self._client.generate_db_auth_token(
|
|
143
|
+
DBHostname=self._hostname,
|
|
144
|
+
Port=self._port,
|
|
145
|
+
DBUsername=self._username,
|
|
146
|
+
)
|
|
147
|
+
# RDS auth tokens are valid for 15 minutes
|
|
148
|
+
return token, time.time() + 900
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_token_provider(
|
|
152
|
+
auth_config: dict[str, Any],
|
|
153
|
+
db_config: dict[str, Any] | None = None,
|
|
154
|
+
) -> PgTokenProvider:
|
|
155
|
+
"""Create a token provider from the ``auth_provider`` config block."""
|
|
156
|
+
if "azure_wif" in auth_config:
|
|
157
|
+
azure_cfg = auth_config["azure_wif"]
|
|
158
|
+
return AzureWifTokenProvider(
|
|
159
|
+
scope=azure_cfg.get("scope", "https://ossrdbms-aad.database.windows.net/.default"),
|
|
160
|
+
)
|
|
161
|
+
elif "gcp_wif" in auth_config:
|
|
162
|
+
return GcpWifTokenProvider()
|
|
163
|
+
elif "aws_wif" in auth_config:
|
|
164
|
+
aws_cfg = auth_config["aws_wif"]
|
|
165
|
+
if db_config is None:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"AWS WIF requires postgres_db config (not postgres_url) to"
|
|
168
|
+
" extract hostname/port/username"
|
|
169
|
+
)
|
|
170
|
+
return AwsWifTokenProvider(
|
|
171
|
+
hostname=db_config["hostname"],
|
|
172
|
+
port=db_config.get("port", 5432),
|
|
173
|
+
username=db_config["username"],
|
|
174
|
+
region=aws_cfg["region"],
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
raise ValueError(f"Unknown auth_provider config: {auth_config}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def register_do_connect_hook(
|
|
181
|
+
engine: sqlalchemy.engine.Engine,
|
|
182
|
+
token_provider: PgTokenProvider,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Register a ``do_connect`` event listener that injects a fresh token as
|
|
185
|
+
the password before each DBAPI connection.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
@event.listens_for(engine, "do_connect")
|
|
189
|
+
def _inject_token(
|
|
190
|
+
dialect: Any,
|
|
191
|
+
conn_rec: Any,
|
|
192
|
+
cargs: list[Any],
|
|
193
|
+
cparams: dict[str, Any],
|
|
194
|
+
) -> None:
|
|
195
|
+
cparams["password"] = token_provider.get_token()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from collections.abc import Iterator, Mapping, Sequence
|
|
2
2
|
from contextlib import contextmanager
|
|
3
|
-
from typing import Any, ContextManager, cast # noqa: UP035
|
|
3
|
+
from typing import TYPE_CHECKING, Any, ContextManager, cast # noqa: UP035
|
|
4
4
|
|
|
5
5
|
import dagster._check as check
|
|
6
6
|
import sqlalchemy as db
|
|
@@ -36,6 +36,8 @@ from sqlalchemy.engine import Connection
|
|
|
36
36
|
|
|
37
37
|
from dagster_postgres.utils import (
|
|
38
38
|
create_pg_connection,
|
|
39
|
+
create_pg_engine,
|
|
40
|
+
get_token_provider_from_config,
|
|
39
41
|
pg_alembic_config,
|
|
40
42
|
pg_url_from_config,
|
|
41
43
|
retry_pg_connection_fn,
|
|
@@ -43,6 +45,9 @@ from dagster_postgres.utils import (
|
|
|
43
45
|
set_pg_statement_timeout,
|
|
44
46
|
)
|
|
45
47
|
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from dagster_postgres.auth import PgTokenProvider
|
|
50
|
+
|
|
46
51
|
CHANNEL_NAME = "run_events"
|
|
47
52
|
|
|
48
53
|
|
|
@@ -56,7 +61,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
|
|
|
56
61
|
To use Postgres for all of the components of your instance storage, you can add the following
|
|
57
62
|
block to your ``dagster.yaml``:
|
|
58
63
|
|
|
59
|
-
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/
|
|
64
|
+
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
|
|
60
65
|
:caption: dagster.yaml
|
|
61
66
|
:lines: 1-8
|
|
62
67
|
:language: YAML
|
|
@@ -65,7 +70,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
|
|
|
65
70
|
configuring your event log storage to use Postgres, you can add a block such as the following
|
|
66
71
|
to your ``dagster.yaml``:
|
|
67
72
|
|
|
68
|
-
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/
|
|
73
|
+
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg-legacy.yaml
|
|
69
74
|
:caption: dagster.yaml
|
|
70
75
|
:lines: 12-21
|
|
71
76
|
:language: YAML
|
|
@@ -80,16 +85,21 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
|
|
|
80
85
|
postgres_url: str,
|
|
81
86
|
should_autocreate_tables: bool = True,
|
|
82
87
|
inst_data: ConfigurableClassData | None = None,
|
|
88
|
+
token_provider: "PgTokenProvider | None" = None,
|
|
83
89
|
):
|
|
84
90
|
self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
|
|
85
91
|
self.postgres_url = check.str_param(postgres_url, "postgres_url")
|
|
86
92
|
self.should_autocreate_tables = check.bool_param(
|
|
87
93
|
should_autocreate_tables, "should_autocreate_tables"
|
|
88
94
|
)
|
|
95
|
+
self._token_provider = token_provider
|
|
89
96
|
|
|
90
97
|
# Default to not holding any connections open to prevent accumulating connections per DagsterInstance
|
|
91
|
-
self._engine =
|
|
92
|
-
self.postgres_url,
|
|
98
|
+
self._engine = create_pg_engine(
|
|
99
|
+
self.postgres_url,
|
|
100
|
+
self._token_provider,
|
|
101
|
+
isolation_level="AUTOCOMMIT",
|
|
102
|
+
poolclass=db_pool.NullPool,
|
|
93
103
|
)
|
|
94
104
|
self._event_watcher: SqlPollingEventWatcher | None = None
|
|
95
105
|
|
|
@@ -125,7 +135,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
|
|
|
125
135
|
existing_options = self._engine.url.query.get("options")
|
|
126
136
|
if existing_options:
|
|
127
137
|
kwargs["connect_args"] = {"options": existing_options}
|
|
128
|
-
self._engine =
|
|
138
|
+
self._engine = create_pg_engine(self.postgres_url, self._token_provider, **kwargs)
|
|
129
139
|
event.listen(
|
|
130
140
|
self._engine,
|
|
131
141
|
"connect",
|
|
@@ -153,6 +163,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
|
|
|
153
163
|
inst_data=inst_data,
|
|
154
164
|
postgres_url=pg_url_from_config(config_value),
|
|
155
165
|
should_autocreate_tables=config_value.get("should_autocreate_tables", True),
|
|
166
|
+
token_provider=get_token_provider_from_config(config_value),
|
|
156
167
|
)
|
|
157
168
|
|
|
158
169
|
@staticmethod
|
|
@@ -379,7 +390,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
|
|
|
379
390
|
),
|
|
380
391
|
) as cursor_res,
|
|
381
392
|
):
|
|
382
|
-
return deserialize_value(cursor_res.scalar(), EventLogEntry)
|
|
393
|
+
return deserialize_value(cursor_res.scalar(), EventLogEntry)
|
|
383
394
|
|
|
384
395
|
def end_watch(self, run_id: str, handler: EventHandlerFn) -> None:
|
|
385
396
|
if self._event_watcher:
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/run_storage/run_storage.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import zlib
|
|
2
2
|
from collections.abc import Mapping
|
|
3
|
-
from typing import ContextManager # noqa: UP035
|
|
3
|
+
from typing import TYPE_CHECKING, ContextManager # noqa: UP035
|
|
4
4
|
|
|
5
5
|
import dagster._check as check
|
|
6
6
|
import sqlalchemy as db
|
|
@@ -31,6 +31,8 @@ from sqlalchemy.engine import Connection
|
|
|
31
31
|
|
|
32
32
|
from dagster_postgres.utils import (
|
|
33
33
|
create_pg_connection,
|
|
34
|
+
create_pg_engine,
|
|
35
|
+
get_token_provider_from_config,
|
|
34
36
|
pg_alembic_config,
|
|
35
37
|
pg_url_from_config,
|
|
36
38
|
retry_pg_connection_fn,
|
|
@@ -38,6 +40,9 @@ from dagster_postgres.utils import (
|
|
|
38
40
|
set_pg_statement_timeout,
|
|
39
41
|
)
|
|
40
42
|
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from dagster_postgres.auth import PgTokenProvider
|
|
45
|
+
|
|
41
46
|
|
|
42
47
|
class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
|
|
43
48
|
"""Postgres-backed run storage.
|
|
@@ -49,7 +54,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
|
|
|
49
54
|
To use Postgres for all of the components of your instance storage, you can add the following
|
|
50
55
|
block to your ``dagster.yaml``:
|
|
51
56
|
|
|
52
|
-
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/
|
|
57
|
+
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
|
|
53
58
|
:caption: dagster.yaml
|
|
54
59
|
:lines: 1-8
|
|
55
60
|
:language: YAML
|
|
@@ -58,7 +63,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
|
|
|
58
63
|
configuring your run storage to use Postgres, you can add a block such as the following
|
|
59
64
|
to your ``dagster.yaml``:
|
|
60
65
|
|
|
61
|
-
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/
|
|
66
|
+
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg-legacy.yaml
|
|
62
67
|
:caption: dagster.yaml
|
|
63
68
|
:lines: 1-10
|
|
64
69
|
:language: YAML
|
|
@@ -72,16 +77,19 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
|
|
|
72
77
|
postgres_url: str,
|
|
73
78
|
should_autocreate_tables: bool = True,
|
|
74
79
|
inst_data: ConfigurableClassData | None = None,
|
|
80
|
+
token_provider: "PgTokenProvider | None" = None,
|
|
75
81
|
):
|
|
76
82
|
self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
|
|
77
83
|
self.postgres_url = postgres_url
|
|
78
84
|
self.should_autocreate_tables = check.bool_param(
|
|
79
85
|
should_autocreate_tables, "should_autocreate_tables"
|
|
80
86
|
)
|
|
87
|
+
self._token_provider = token_provider
|
|
81
88
|
|
|
82
89
|
# Default to not holding any connections open to prevent accumulating connections per DagsterInstance
|
|
83
|
-
self._engine =
|
|
90
|
+
self._engine = create_pg_engine(
|
|
84
91
|
self.postgres_url,
|
|
92
|
+
self._token_provider,
|
|
85
93
|
isolation_level="AUTOCOMMIT",
|
|
86
94
|
poolclass=db_pool.NullPool,
|
|
87
95
|
)
|
|
@@ -121,7 +129,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
|
|
|
121
129
|
existing_options = self._engine.url.query.get("options")
|
|
122
130
|
if existing_options:
|
|
123
131
|
kwargs["connect_args"] = {"options": existing_options}
|
|
124
|
-
self._engine =
|
|
132
|
+
self._engine = create_pg_engine(self.postgres_url, self._token_provider, **kwargs)
|
|
125
133
|
event.listen(
|
|
126
134
|
self._engine,
|
|
127
135
|
"connect",
|
|
@@ -144,6 +152,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
|
|
|
144
152
|
inst_data=inst_data,
|
|
145
153
|
postgres_url=pg_url_from_config(config_value),
|
|
146
154
|
should_autocreate_tables=config_value.get("should_autocreate_tables", True),
|
|
155
|
+
token_provider=get_token_provider_from_config(config_value),
|
|
147
156
|
)
|
|
148
157
|
|
|
149
158
|
@staticmethod
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from collections.abc import Sequence
|
|
2
|
-
from typing import ContextManager # noqa: UP035
|
|
2
|
+
from typing import TYPE_CHECKING, ContextManager # noqa: UP035
|
|
3
3
|
|
|
4
4
|
import dagster._check as check
|
|
5
5
|
import sqlalchemy as db
|
|
@@ -31,6 +31,8 @@ from sqlalchemy.engine import Connection
|
|
|
31
31
|
|
|
32
32
|
from dagster_postgres.utils import (
|
|
33
33
|
create_pg_connection,
|
|
34
|
+
create_pg_engine,
|
|
35
|
+
get_token_provider_from_config,
|
|
34
36
|
pg_alembic_config,
|
|
35
37
|
pg_url_from_config,
|
|
36
38
|
retry_pg_connection_fn,
|
|
@@ -38,6 +40,9 @@ from dagster_postgres.utils import (
|
|
|
38
40
|
set_pg_statement_timeout,
|
|
39
41
|
)
|
|
40
42
|
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from dagster_postgres.auth import PgTokenProvider
|
|
45
|
+
|
|
41
46
|
|
|
42
47
|
class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
|
|
43
48
|
"""Postgres-backed run storage.
|
|
@@ -49,7 +54,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
|
|
|
49
54
|
To use Postgres for all of the components of your instance storage, you can add the following
|
|
50
55
|
block to your ``dagster.yaml``:
|
|
51
56
|
|
|
52
|
-
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/
|
|
57
|
+
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
|
|
53
58
|
:caption: dagster.yaml
|
|
54
59
|
:lines: 1-8
|
|
55
60
|
:language: YAML
|
|
@@ -58,7 +63,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
|
|
|
58
63
|
configuring your schedule storage to use Postgres, you can add a block such as the following
|
|
59
64
|
to your ``dagster.yaml``:
|
|
60
65
|
|
|
61
|
-
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/
|
|
66
|
+
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg-legacy.yaml
|
|
62
67
|
:caption: dagster.yaml
|
|
63
68
|
:lines: 23-32
|
|
64
69
|
:language: YAML
|
|
@@ -72,16 +77,21 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
|
|
|
72
77
|
postgres_url: str,
|
|
73
78
|
should_autocreate_tables: bool = True,
|
|
74
79
|
inst_data: ConfigurableClassData | None = None,
|
|
80
|
+
token_provider: "PgTokenProvider | None" = None,
|
|
75
81
|
):
|
|
76
82
|
self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
|
|
77
83
|
self.postgres_url = postgres_url
|
|
78
84
|
self.should_autocreate_tables = check.bool_param(
|
|
79
85
|
should_autocreate_tables, "should_autocreate_tables"
|
|
80
86
|
)
|
|
87
|
+
self._token_provider = token_provider
|
|
81
88
|
|
|
82
89
|
# Default to not holding any connections open to prevent accumulating connections per DagsterInstance
|
|
83
|
-
self._engine =
|
|
84
|
-
self.postgres_url,
|
|
90
|
+
self._engine = create_pg_engine(
|
|
91
|
+
self.postgres_url,
|
|
92
|
+
self._token_provider,
|
|
93
|
+
isolation_level="AUTOCOMMIT",
|
|
94
|
+
poolclass=db_pool.NullPool,
|
|
85
95
|
)
|
|
86
96
|
|
|
87
97
|
# Stamp and create tables if the main table does not exist (we can't check alembic
|
|
@@ -117,7 +127,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
|
|
|
117
127
|
existing_options = self._engine.url.query.get("options")
|
|
118
128
|
if existing_options:
|
|
119
129
|
kwargs["connect_args"] = {"options": existing_options}
|
|
120
|
-
self._engine =
|
|
130
|
+
self._engine = create_pg_engine(self.postgres_url, self._token_provider, **kwargs)
|
|
121
131
|
event.listen(
|
|
122
132
|
self._engine,
|
|
123
133
|
"connect",
|
|
@@ -140,6 +150,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
|
|
|
140
150
|
inst_data=inst_data,
|
|
141
151
|
postgres_url=pg_url_from_config(config_value),
|
|
142
152
|
should_autocreate_tables=config_value.get("should_autocreate_tables", True),
|
|
153
|
+
token_provider=get_token_provider_from_config(config_value),
|
|
143
154
|
)
|
|
144
155
|
|
|
145
156
|
@staticmethod
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
1
3
|
from dagster import _check as check
|
|
2
4
|
from dagster._config.config_schema import UserConfigSchema
|
|
3
5
|
from dagster._core.storage.base_storage import DagsterStorage
|
|
@@ -10,7 +12,10 @@ from dagster._serdes import ConfigurableClass, ConfigurableClassData
|
|
|
10
12
|
from dagster_postgres.event_log import PostgresEventLogStorage
|
|
11
13
|
from dagster_postgres.run_storage import PostgresRunStorage
|
|
12
14
|
from dagster_postgres.schedule_storage import PostgresScheduleStorage
|
|
13
|
-
from dagster_postgres.utils import pg_url_from_config
|
|
15
|
+
from dagster_postgres.utils import get_token_provider_from_config, pg_url_from_config
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from dagster_postgres.auth import PgTokenProvider
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
|
|
@@ -23,7 +28,7 @@ class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
|
|
|
23
28
|
To use Postgres for storage, you can add a block such as the following to your
|
|
24
29
|
``dagster.yaml``:
|
|
25
30
|
|
|
26
|
-
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/
|
|
31
|
+
.. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
|
|
27
32
|
:caption: dagster.yaml
|
|
28
33
|
:lines: 1-8
|
|
29
34
|
:language: YAML
|
|
@@ -34,18 +39,25 @@ class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
|
|
|
34
39
|
|
|
35
40
|
def __init__(
|
|
36
41
|
self,
|
|
37
|
-
postgres_url,
|
|
38
|
-
should_autocreate_tables=True,
|
|
42
|
+
postgres_url: str,
|
|
43
|
+
should_autocreate_tables: bool = True,
|
|
39
44
|
inst_data: ConfigurableClassData | None = None,
|
|
45
|
+
token_provider: "PgTokenProvider | None" = None,
|
|
40
46
|
):
|
|
41
47
|
self.postgres_url = postgres_url
|
|
42
48
|
self.should_autocreate_tables = check.bool_param(
|
|
43
49
|
should_autocreate_tables, "should_autocreate_tables"
|
|
44
50
|
)
|
|
45
51
|
self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
|
|
46
|
-
self._run_storage = PostgresRunStorage(
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
self._run_storage = PostgresRunStorage(
|
|
53
|
+
postgres_url, should_autocreate_tables, token_provider=token_provider
|
|
54
|
+
)
|
|
55
|
+
self._event_log_storage = PostgresEventLogStorage(
|
|
56
|
+
postgres_url, should_autocreate_tables, token_provider=token_provider
|
|
57
|
+
)
|
|
58
|
+
self._schedule_storage = PostgresScheduleStorage(
|
|
59
|
+
postgres_url, should_autocreate_tables, token_provider=token_provider
|
|
60
|
+
)
|
|
49
61
|
super().__init__()
|
|
50
62
|
|
|
51
63
|
@property
|
|
@@ -64,6 +76,7 @@ class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
|
|
|
64
76
|
inst_data=inst_data,
|
|
65
77
|
postgres_url=pg_url_from_config(config_value),
|
|
66
78
|
should_autocreate_tables=config_value.get("should_autocreate_tables", True),
|
|
79
|
+
token_provider=get_token_provider_from_config(config_value),
|
|
67
80
|
)
|
|
68
81
|
|
|
69
82
|
@property
|
|
@@ -2,7 +2,7 @@ import logging
|
|
|
2
2
|
import time
|
|
3
3
|
from collections.abc import Callable, Iterator, Mapping
|
|
4
4
|
from contextlib import contextmanager
|
|
5
|
-
from typing import Any, TypeVar
|
|
5
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
6
|
from urllib.parse import quote, urlencode
|
|
7
7
|
|
|
8
8
|
import alembic.config
|
|
@@ -13,9 +13,12 @@ from dagster._core.definitions.policy import Backoff, Jitter, calculate_delay
|
|
|
13
13
|
|
|
14
14
|
# re-export
|
|
15
15
|
from dagster._core.storage.config import pg_config as pg_config
|
|
16
|
-
from dagster._core.storage.sql import get_alembic_config
|
|
16
|
+
from dagster._core.storage.sql import create_engine, get_alembic_config
|
|
17
17
|
from sqlalchemy.engine import Connection
|
|
18
18
|
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from dagster_postgres.auth import PgTokenProvider
|
|
21
|
+
|
|
19
22
|
T = TypeVar("T")
|
|
20
23
|
|
|
21
24
|
|
|
@@ -41,14 +44,18 @@ def pg_url_from_config(config_value: Mapping[str, Any]) -> str:
|
|
|
41
44
|
|
|
42
45
|
def get_conn_string(
|
|
43
46
|
username: str,
|
|
44
|
-
password: str,
|
|
45
|
-
hostname: str,
|
|
46
|
-
db_name: str,
|
|
47
|
+
password: str = "",
|
|
48
|
+
hostname: str = "",
|
|
49
|
+
db_name: str = "",
|
|
47
50
|
port: str = "5432",
|
|
48
51
|
params: Mapping[str, object] | None = None,
|
|
49
52
|
scheme: str = "postgresql",
|
|
50
53
|
) -> str:
|
|
51
|
-
|
|
54
|
+
if password:
|
|
55
|
+
userinfo = f"{quote(username)}:{quote(password)}"
|
|
56
|
+
else:
|
|
57
|
+
userinfo = quote(username)
|
|
58
|
+
uri = f"{scheme}://{userinfo}@{hostname}:{port}/{db_name}"
|
|
52
59
|
|
|
53
60
|
if params:
|
|
54
61
|
query_string = f"{urlencode(params, quote_via=quote)}"
|
|
@@ -157,3 +164,41 @@ def set_pg_statement_timeout(conn: Any, millis: int):
|
|
|
157
164
|
with conn:
|
|
158
165
|
with conn.cursor() as curs:
|
|
159
166
|
curs.execute(f"SET statement_timeout = {millis};")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def create_pg_engine(
|
|
170
|
+
postgres_url: str,
|
|
171
|
+
token_provider: "PgTokenProvider | None" = None,
|
|
172
|
+
**engine_kwargs: Any,
|
|
173
|
+
) -> sqlalchemy.engine.Engine:
|
|
174
|
+
"""Create a SQLAlchemy engine, optionally with WIF token injection via
|
|
175
|
+
the ``do_connect`` event hook.
|
|
176
|
+
"""
|
|
177
|
+
engine = create_engine(postgres_url, **engine_kwargs)
|
|
178
|
+
if token_provider is not None:
|
|
179
|
+
from dagster_postgres.auth import register_do_connect_hook
|
|
180
|
+
|
|
181
|
+
register_do_connect_hook(engine, token_provider)
|
|
182
|
+
return engine
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_token_provider_from_config(
|
|
186
|
+
config_value: Mapping[str, Any],
|
|
187
|
+
) -> "PgTokenProvider | None":
|
|
188
|
+
"""Extract and build a token provider from config, or return ``None`` for
|
|
189
|
+
password auth.
|
|
190
|
+
"""
|
|
191
|
+
auth_config = config_value.get("auth_provider")
|
|
192
|
+
if auth_config is None:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
check.invariant(
|
|
196
|
+
"postgres_url" not in config_value or not config_value["postgres_url"],
|
|
197
|
+
"auth_provider cannot be used with postgres_url. Use postgres_db instead"
|
|
198
|
+
" so that tokens can be injected at the DBAPI connection level.",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
from dagster_postgres.auth import create_token_provider
|
|
202
|
+
|
|
203
|
+
db_config = config_value.get("postgres_db")
|
|
204
|
+
return create_token_provider(auth_config, db_config)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.29.4"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import sqlalchemy
|
|
7
|
+
import sqlalchemy.exc
|
|
8
|
+
import sqlalchemy.pool
|
|
9
|
+
from dagster_postgres.auth import (
|
|
10
|
+
AwsWifTokenProvider,
|
|
11
|
+
AzureWifTokenProvider,
|
|
12
|
+
GcpWifTokenProvider,
|
|
13
|
+
PgTokenProvider,
|
|
14
|
+
create_token_provider,
|
|
15
|
+
register_do_connect_hook,
|
|
16
|
+
)
|
|
17
|
+
from dagster_postgres.utils import get_token_provider_from_config
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Helpers
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeTokenProvider(PgTokenProvider):
|
|
25
|
+
"""A concrete token provider that returns a fixed token for testing."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, token: str = "fake-token", ttl: float = 3600) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._token = token
|
|
30
|
+
self._ttl = ttl
|
|
31
|
+
self.fetch_count = 0
|
|
32
|
+
|
|
33
|
+
def _fetch_token(self) -> tuple[str, float]:
|
|
34
|
+
self.fetch_count += 1
|
|
35
|
+
return self._token, time.time() + self._ttl
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Token caching
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestTokenCaching:
|
|
44
|
+
def test_caches_token_within_ttl(self) -> None:
|
|
45
|
+
provider = FakeTokenProvider(token="tok-1", ttl=3600)
|
|
46
|
+
assert provider.get_token() == "tok-1"
|
|
47
|
+
assert provider.get_token() == "tok-1"
|
|
48
|
+
assert provider.fetch_count == 1
|
|
49
|
+
|
|
50
|
+
def test_refreshes_on_expiry(self) -> None:
|
|
51
|
+
provider = FakeTokenProvider(token="tok-1", ttl=0)
|
|
52
|
+
# ttl=0 means the token is already expired on creation
|
|
53
|
+
tok1 = provider.get_token()
|
|
54
|
+
assert tok1 == "tok-1"
|
|
55
|
+
assert provider.fetch_count == 1
|
|
56
|
+
|
|
57
|
+
# Second call should refresh because token is already past expiry
|
|
58
|
+
tok2 = provider.get_token()
|
|
59
|
+
assert tok2 == "tok-1"
|
|
60
|
+
assert provider.fetch_count == 2
|
|
61
|
+
|
|
62
|
+
def test_refreshes_within_margin(self) -> None:
|
|
63
|
+
# Token expires in 4 minutes — within the 5-minute refresh margin
|
|
64
|
+
provider = FakeTokenProvider(token="tok-1", ttl=240)
|
|
65
|
+
provider.get_token()
|
|
66
|
+
assert provider.fetch_count == 1
|
|
67
|
+
|
|
68
|
+
# Should refresh because 240s < 300s margin
|
|
69
|
+
provider.get_token()
|
|
70
|
+
assert provider.fetch_count == 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Thread safety
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestThreadSafety:
|
|
79
|
+
def test_concurrent_get_token(self) -> None:
|
|
80
|
+
provider = FakeTokenProvider(token="tok-1", ttl=3600)
|
|
81
|
+
results: list[str] = []
|
|
82
|
+
errors: list[Exception] = []
|
|
83
|
+
|
|
84
|
+
def worker() -> None:
|
|
85
|
+
try:
|
|
86
|
+
results.append(provider.get_token())
|
|
87
|
+
except Exception as e:
|
|
88
|
+
errors.append(e)
|
|
89
|
+
|
|
90
|
+
threads = [threading.Thread(target=worker) for _ in range(20)]
|
|
91
|
+
for t in threads:
|
|
92
|
+
t.start()
|
|
93
|
+
for t in threads:
|
|
94
|
+
t.join()
|
|
95
|
+
|
|
96
|
+
assert not errors
|
|
97
|
+
assert all(r == "tok-1" for r in results)
|
|
98
|
+
# May have fetched more than once due to race at startup, but should be bounded
|
|
99
|
+
assert provider.fetch_count >= 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# do_connect hook
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestDoConnectHook:
|
|
108
|
+
def test_hook_injects_token_on_connect(self) -> None:
|
|
109
|
+
"""Verify that register_do_connect_hook causes the provider's token
|
|
110
|
+
to be used as the password when psycopg2.connect is called.
|
|
111
|
+
"""
|
|
112
|
+
provider = FakeTokenProvider(token="injected-password", ttl=3600)
|
|
113
|
+
engine = sqlalchemy.create_engine(
|
|
114
|
+
"postgresql://user:dummy@localhost:5432/db",
|
|
115
|
+
poolclass=sqlalchemy.pool.NullPool,
|
|
116
|
+
)
|
|
117
|
+
register_do_connect_hook(engine, provider)
|
|
118
|
+
|
|
119
|
+
# Patch psycopg2.connect to capture the password that SQLAlchemy passes
|
|
120
|
+
captured_kwargs: dict[str, object] = {}
|
|
121
|
+
with patch("psycopg2.connect") as mock_connect:
|
|
122
|
+
|
|
123
|
+
def capture_connect(*args: object, **kwargs: object) -> None:
|
|
124
|
+
captured_kwargs.update(kwargs)
|
|
125
|
+
raise sqlalchemy.exc.OperationalError("test", {}, Exception("skip"))
|
|
126
|
+
|
|
127
|
+
mock_connect.side_effect = capture_connect
|
|
128
|
+
|
|
129
|
+
# Attempting to connect will invoke do_connect, which injects the token,
|
|
130
|
+
# then psycopg2.connect is called with the modified password.
|
|
131
|
+
try:
|
|
132
|
+
with engine.connect():
|
|
133
|
+
pass
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# The do_connect hook should have replaced "dummy" with the token
|
|
138
|
+
assert captured_kwargs.get("password") == "injected-password"
|
|
139
|
+
assert provider.fetch_count == 1
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Factory function
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestCreateTokenProvider:
|
|
148
|
+
def test_azure_wif(self) -> None:
|
|
149
|
+
provider = create_token_provider({"azure_wif": {"scope": "custom-scope"}})
|
|
150
|
+
assert isinstance(provider, AzureWifTokenProvider)
|
|
151
|
+
assert provider._scope == "custom-scope" # noqa: SLF001
|
|
152
|
+
|
|
153
|
+
def test_azure_wif_default_scope(self) -> None:
|
|
154
|
+
provider = create_token_provider({"azure_wif": {}})
|
|
155
|
+
assert isinstance(provider, AzureWifTokenProvider)
|
|
156
|
+
assert "ossrdbms-aad" in provider._scope # noqa: SLF001
|
|
157
|
+
|
|
158
|
+
def test_gcp_wif(self) -> None:
|
|
159
|
+
provider = create_token_provider({"gcp_wif": {}})
|
|
160
|
+
assert isinstance(provider, GcpWifTokenProvider)
|
|
161
|
+
|
|
162
|
+
def test_aws_wif(self) -> None:
|
|
163
|
+
db_config = {"hostname": "my-host", "port": 5432, "username": "myuser"}
|
|
164
|
+
provider = create_token_provider({"aws_wif": {"region": "us-east-1"}}, db_config)
|
|
165
|
+
assert isinstance(provider, AwsWifTokenProvider)
|
|
166
|
+
assert provider._region == "us-east-1" # noqa: SLF001
|
|
167
|
+
assert provider._hostname == "my-host" # noqa: SLF001
|
|
168
|
+
|
|
169
|
+
def test_aws_wif_requires_db_config(self) -> None:
|
|
170
|
+
with pytest.raises(ValueError, match="AWS WIF requires postgres_db"):
|
|
171
|
+
create_token_provider({"aws_wif": {"region": "us-east-1"}})
|
|
172
|
+
|
|
173
|
+
def test_unknown_provider(self) -> None:
|
|
174
|
+
with pytest.raises(ValueError, match="Unknown auth_provider"):
|
|
175
|
+
create_token_provider({"unknown_wif": {}})
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Config extraction
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TestGetTokenProviderFromConfig:
|
|
184
|
+
def test_returns_none_without_auth_provider(self) -> None:
|
|
185
|
+
config = {
|
|
186
|
+
"postgres_db": {"username": "u", "password": "p", "hostname": "h", "db_name": "d"}
|
|
187
|
+
}
|
|
188
|
+
assert get_token_provider_from_config(config) is None
|
|
189
|
+
|
|
190
|
+
def test_returns_provider_with_auth_provider(self) -> None:
|
|
191
|
+
config = {
|
|
192
|
+
"postgres_db": {"username": "u", "hostname": "h", "db_name": "d"},
|
|
193
|
+
"auth_provider": {"azure_wif": {}},
|
|
194
|
+
}
|
|
195
|
+
provider = get_token_provider_from_config(config)
|
|
196
|
+
assert isinstance(provider, AzureWifTokenProvider)
|
|
197
|
+
|
|
198
|
+
def test_rejects_auth_provider_with_postgres_url(self) -> None:
|
|
199
|
+
config = {
|
|
200
|
+
"postgres_url": "postgresql://user:pass@host/db",
|
|
201
|
+
"auth_provider": {"azure_wif": {}},
|
|
202
|
+
}
|
|
203
|
+
with pytest.raises(Exception, match="auth_provider cannot be used with postgres_url"):
|
|
204
|
+
get_token_provider_from_config(config)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Cloud provider import errors
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestImportErrors:
|
|
213
|
+
def test_azure_missing_import(self) -> None:
|
|
214
|
+
provider = AzureWifTokenProvider()
|
|
215
|
+
with patch.dict("sys.modules", {"azure.identity": None, "azure": None}):
|
|
216
|
+
with pytest.raises(ImportError, match="azure-identity"):
|
|
217
|
+
provider._fetch_token() # noqa: SLF001
|
|
218
|
+
|
|
219
|
+
def test_gcp_missing_import(self) -> None:
|
|
220
|
+
provider = GcpWifTokenProvider()
|
|
221
|
+
with patch.dict("sys.modules", {"google.auth": None, "google": None}):
|
|
222
|
+
with pytest.raises(ImportError, match="google-auth"):
|
|
223
|
+
provider._fetch_token() # noqa: SLF001
|
|
224
|
+
|
|
225
|
+
def test_aws_missing_import(self) -> None:
|
|
226
|
+
provider = AwsWifTokenProvider(hostname="h", port=5432, username="u", region="us-east-1")
|
|
227
|
+
with patch.dict("sys.modules", {"boto3": None}):
|
|
228
|
+
with pytest.raises(ImportError, match="boto3"):
|
|
229
|
+
provider._fetch_token() # noqa: SLF001
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "dagster-postgres"
|
|
9
|
-
version = "0.29.
|
|
9
|
+
version = "0.29.4"
|
|
10
10
|
description = "A Dagster integration for postgres"
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "Dagster Labs", email = "hello@dagsterlabs.com" },
|
|
@@ -22,11 +22,25 @@ classifiers = [
|
|
|
22
22
|
"Operating System :: OS Independent",
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
|
-
"dagster==1.13.
|
|
25
|
+
"dagster==1.13.4",
|
|
26
26
|
"psycopg2-binary",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
[project.optional-dependencies]
|
|
30
|
+
azure = [
|
|
31
|
+
"azure-identity>=1.12.0",
|
|
32
|
+
]
|
|
33
|
+
gcp = [
|
|
34
|
+
"google-auth>=2.0.0",
|
|
35
|
+
]
|
|
36
|
+
aws = [
|
|
37
|
+
"boto3>=1.26.0",
|
|
38
|
+
]
|
|
39
|
+
wif = [
|
|
40
|
+
"azure-identity>=1.12.0",
|
|
41
|
+
"google-auth>=2.0.0",
|
|
42
|
+
"boto3>=1.26.0",
|
|
43
|
+
]
|
|
30
44
|
test = []
|
|
31
45
|
|
|
32
46
|
[project.urls]
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
[tox]
|
|
2
|
+
requires =
|
|
3
|
+
tox-uv>=1
|
|
2
4
|
skipsdist = true
|
|
3
5
|
|
|
4
6
|
[testenv]
|
|
@@ -9,7 +11,6 @@ passenv =
|
|
|
9
11
|
BUILDKITE*
|
|
10
12
|
PYTEST_ADDOPTS
|
|
11
13
|
PYTEST_PLUGINS
|
|
12
|
-
install_command = uv pip install {opts} {packages}
|
|
13
14
|
deps =
|
|
14
15
|
storage_tests_sqlalchemy_1_3: sqlalchemy<1.4
|
|
15
16
|
-e ../../dagster[test]
|
|
@@ -21,6 +22,6 @@ allowlist_externals =
|
|
|
21
22
|
/bin/bash
|
|
22
23
|
uv
|
|
23
24
|
commands =
|
|
24
|
-
!windows: /bin/bash -c '! pip list --exclude-editable | grep -e dagster'
|
|
25
|
+
!windows: /bin/bash -c '! uv pip list --exclude-editable | grep -e dagster'
|
|
25
26
|
storage_tests: pytest -vv {posargs}
|
|
26
27
|
storage_tests_sqlalchemy_1_3: pytest -vv {posargs}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.29.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/run_storage/__init__.py
RENAMED
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/schedule_storage/__init__.py
RENAMED
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres/test_fixtures/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/__init__.py
RENAMED
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/compat_tests/dagster.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_event_log.py
RENAMED
|
File without changes
|
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_run_storage.py
RENAMED
|
File without changes
|
{dagster_postgres-0.29.2 → dagster_postgres-0.29.4}/dagster_postgres_tests/test_schedule_storage.py
RENAMED
|
File without changes
|
|
File without changes
|