vibedata-dlt-redshift-studio 0.1.0__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.
- vibedata_dlt_redshift_studio-0.1.0/.gitignore +8 -0
- vibedata_dlt_redshift_studio-0.1.0/PKG-INFO +58 -0
- vibedata_dlt_redshift_studio-0.1.0/README.md +43 -0
- vibedata_dlt_redshift_studio-0.1.0/pyproject.toml +38 -0
- vibedata_dlt_redshift_studio-0.1.0/src/vibedata/dlt/redshift/__init__.py +32 -0
- vibedata_dlt_redshift_studio-0.1.0/src/vibedata/dlt/redshift/bootstrap.py +173 -0
- vibedata_dlt_redshift_studio-0.1.0/tests/conftest.py +33 -0
- vibedata_dlt_redshift_studio-0.1.0/tests/test_bootstrap.py +116 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vibedata-dlt-redshift-studio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: VibeData dlt runtime — Amazon Redshift Studio execution unit (intent-local runs targeting an Intent's Ephemeral Redshift database): the brokered database password for dlt's redshift destination.
|
|
5
|
+
Author-email: Vibedata <eng@acceleratedata.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: dlt[redshift]>=1.30
|
|
9
|
+
Requires-Dist: vibedata-dlt-core
|
|
10
|
+
Requires-Dist: vibedata-dlt-studio-secrets
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# vibedata-dlt-redshift-studio
|
|
17
|
+
|
|
18
|
+
The dlt runtime execution unit for an Amazon Redshift Domain, installed in the
|
|
19
|
+
Studio-local intent venv.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import dlt
|
|
23
|
+
from vibedata.dlt.redshift import setup_environment, finalize
|
|
24
|
+
|
|
25
|
+
setup_environment()
|
|
26
|
+
pipeline = dlt.pipeline(
|
|
27
|
+
pipeline_name="my_pipeline", destination="redshift", dataset_name="analytics"
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
pipeline.run(source)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
finalize(pipeline, error_message=str(e))
|
|
33
|
+
raise
|
|
34
|
+
finalize(pipeline)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`setup_environment()` registers the Domain's Secret Store provider and mints the
|
|
38
|
+
Ephemeral database's password into this process, under dlt's own
|
|
39
|
+
`DESTINATION__REDSHIFT__CREDENTIALS__PASSWORD`. Studio publishes the other four
|
|
40
|
+
coordinates — `HOST`, `PORT`, `DATABASE`, `USERNAME` — into the Intent
|
|
41
|
+
environment under the matching names, and dlt reads those natively, so the
|
|
42
|
+
pipeline names no `VD_*` variable itself.
|
|
43
|
+
|
|
44
|
+
The password is separate because Redshift authenticates with a credential minted
|
|
45
|
+
per invocation, scoped to one database and valid for at most an hour.
|
|
46
|
+
|
|
47
|
+
Without one, dlt refuses to configure the destination and names
|
|
48
|
+
`DESTINATION__REDSHIFT__CREDENTIALS__PASSWORD` — which no authored pipeline
|
|
49
|
+
sets, so this unit fails at the mint and names the Broker instead. A *blank*
|
|
50
|
+
password is the case worth guarding: dlt resolves it cleanly and the load then
|
|
51
|
+
reaches Redshift as a real login attempt, so an empty Broker answer is refused
|
|
52
|
+
rather than set.
|
|
53
|
+
|
|
54
|
+
The minted password lives in `os.environ` for the life of the process, as the
|
|
55
|
+
MotherDuck unit's token does — inherited by any child it spawns.
|
|
56
|
+
|
|
57
|
+
`finalize()` persists the audit tables. A Redshift load writes directly, so
|
|
58
|
+
there is nothing to sync afterwards.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# vibedata-dlt-redshift-studio
|
|
2
|
+
|
|
3
|
+
The dlt runtime execution unit for an Amazon Redshift Domain, installed in the
|
|
4
|
+
Studio-local intent venv.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import dlt
|
|
8
|
+
from vibedata.dlt.redshift import setup_environment, finalize
|
|
9
|
+
|
|
10
|
+
setup_environment()
|
|
11
|
+
pipeline = dlt.pipeline(
|
|
12
|
+
pipeline_name="my_pipeline", destination="redshift", dataset_name="analytics"
|
|
13
|
+
)
|
|
14
|
+
try:
|
|
15
|
+
pipeline.run(source)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
finalize(pipeline, error_message=str(e))
|
|
18
|
+
raise
|
|
19
|
+
finalize(pipeline)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`setup_environment()` registers the Domain's Secret Store provider and mints the
|
|
23
|
+
Ephemeral database's password into this process, under dlt's own
|
|
24
|
+
`DESTINATION__REDSHIFT__CREDENTIALS__PASSWORD`. Studio publishes the other four
|
|
25
|
+
coordinates — `HOST`, `PORT`, `DATABASE`, `USERNAME` — into the Intent
|
|
26
|
+
environment under the matching names, and dlt reads those natively, so the
|
|
27
|
+
pipeline names no `VD_*` variable itself.
|
|
28
|
+
|
|
29
|
+
The password is separate because Redshift authenticates with a credential minted
|
|
30
|
+
per invocation, scoped to one database and valid for at most an hour.
|
|
31
|
+
|
|
32
|
+
Without one, dlt refuses to configure the destination and names
|
|
33
|
+
`DESTINATION__REDSHIFT__CREDENTIALS__PASSWORD` — which no authored pipeline
|
|
34
|
+
sets, so this unit fails at the mint and names the Broker instead. A *blank*
|
|
35
|
+
password is the case worth guarding: dlt resolves it cleanly and the load then
|
|
36
|
+
reaches Redshift as a real login attempt, so an empty Broker answer is refused
|
|
37
|
+
rather than set.
|
|
38
|
+
|
|
39
|
+
The minted password lives in `os.environ` for the life of the process, as the
|
|
40
|
+
MotherDuck unit's token does — inherited by any child it spawns.
|
|
41
|
+
|
|
42
|
+
`finalize()` persists the audit tables. A Redshift load writes directly, so
|
|
43
|
+
there is nothing to sync afterwards.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vibedata-dlt-redshift-studio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "VibeData dlt runtime — Amazon Redshift Studio execution unit (intent-local runs targeting an Intent's Ephemeral Redshift database): the brokered database password for dlt's redshift destination."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Vibedata", email = "eng@acceleratedata.ai" }]
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
# `persist_audit`, which `finalize` delegates to unchanged.
|
|
16
|
+
"vibedata-dlt-core",
|
|
17
|
+
# The Key Vault provider and the kind-gated registration every Studio unit
|
|
18
|
+
# performs before its destination work. A Redshift Domain binds a Secret
|
|
19
|
+
# Store like any other (ADR-0022).
|
|
20
|
+
"vibedata-dlt-studio-secrets",
|
|
21
|
+
# The `redshift` extra is required, not convenience: it is what brings
|
|
22
|
+
# psycopg2, and without it dlt has no usable Redshift destination at all
|
|
23
|
+
# rather than a degraded one. No upper bound — unlike the Warehouse unit,
|
|
24
|
+
# this one patches no private seam of the destination, so a minor bump has
|
|
25
|
+
# nothing here to move. The floor tracks the other units in this workspace
|
|
26
|
+
# rather than a Redshift capability — that destination long predates 1.30.
|
|
27
|
+
"dlt[redshift]>=1.30",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=8.0", "pytest-mock>=3.12"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/vibedata"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""VibeData dlt runtime — Amazon Redshift Studio execution unit.
|
|
2
|
+
|
|
3
|
+
The installed unit for Redshift domains running in the Studio-local intent
|
|
4
|
+
venv. It exposes the stable, unit-agnostic import:
|
|
5
|
+
|
|
6
|
+
from vibedata.dlt.redshift import setup_environment, finalize
|
|
7
|
+
|
|
8
|
+
There is no `vibedata-dlt-redshift-core`: unlike DuckDB and Fabric, Redshift has
|
|
9
|
+
one execution context — the Intent's Ephemeral database — so there is no second
|
|
10
|
+
unit for a core to be shared with. `finalize` therefore delegates straight to
|
|
11
|
+
the workspace-wide audit helper, and `setup_environment` does what the agent
|
|
12
|
+
container needs: the Domain's Secret Store provider, then the brokered database
|
|
13
|
+
password (see `bootstrap`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from vibedata.dlt.core.audit import persist_audit
|
|
19
|
+
|
|
20
|
+
from .bootstrap import RedshiftCredentialError, setup_environment
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def finalize(pipeline: Any, error_message: Optional[str] = None) -> dict[str, Any]:
|
|
24
|
+
"""Persist audit tables. Redshift writes land directly — nothing to sync."""
|
|
25
|
+
return persist_audit(pipeline, pipeline.last_trace, error_message=error_message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"setup_environment",
|
|
30
|
+
"finalize",
|
|
31
|
+
"RedshiftCredentialError",
|
|
32
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bootstrap for STUDIO (intent-local) dlt runs — Redshift platform dialect:
|
|
3
|
+
|
|
4
|
+
import dlt
|
|
5
|
+
from vibedata.dlt.redshift import setup_environment, finalize
|
|
6
|
+
|
|
7
|
+
setup_environment()
|
|
8
|
+
pipeline = dlt.pipeline(pipeline_name="<pipeline_name>",
|
|
9
|
+
destination="redshift",
|
|
10
|
+
dataset_name="<schema>")
|
|
11
|
+
try:
|
|
12
|
+
pipeline.run(source)
|
|
13
|
+
except Exception as e:
|
|
14
|
+
finalize(pipeline, error_message=str(e))
|
|
15
|
+
raise
|
|
16
|
+
finalize(pipeline)
|
|
17
|
+
|
|
18
|
+
This unit contributes exactly one thing to dlt's config chain: the database
|
|
19
|
+
password. Studio already publishes the other four coordinates into the Intent
|
|
20
|
+
environment under dlt's own documented names —
|
|
21
|
+
`DESTINATION__REDSHIFT__CREDENTIALS__{HOST,PORT,DATABASE,USERNAME}` — and dlt
|
|
22
|
+
reads those natively, so a generated pipeline is plain `destination="redshift"`
|
|
23
|
+
with no `VD_*` read of its own.
|
|
24
|
+
|
|
25
|
+
The password is the one field that cannot ride the Intent environment the way a
|
|
26
|
+
host or port does: Redshift authenticates with a credential minted per
|
|
27
|
+
invocation, scoped to exactly one database, and valid for at most an hour. It is
|
|
28
|
+
minted here and set for this process only, which is the same contract the dbt
|
|
29
|
+
shim (`vd-dbt.sh`) holds for `VD_EPHM_REDSHIFT_PASSWORD`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import urllib.error
|
|
38
|
+
import urllib.request
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from vibedata.dlt.studio_secrets import register_secret_provider
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
PLATFORM_ENV = "VD_DOMAIN_DATA_PLATFORM"
|
|
46
|
+
REDSHIFT_PLATFORM = "redshift"
|
|
47
|
+
|
|
48
|
+
# dlt's documented config name for the destination's password. The sibling four
|
|
49
|
+
# are published by Studio and read by dlt without this unit's help.
|
|
50
|
+
DLT_PASSWORD_ENV = "DESTINATION__REDSHIFT__CREDENTIALS__PASSWORD"
|
|
51
|
+
|
|
52
|
+
BROKER_URL_ENV = "VD_CREDENTIAL_BROKER_URL"
|
|
53
|
+
CREDENTIAL_FILE_ENV = "VD_CREDENTIAL_FILE"
|
|
54
|
+
BROKER_LANE_ENV = "VD_CREDENTIAL_LANE"
|
|
55
|
+
CONFIGURATION_REVISION_ENV = "VD_CREDENTIAL_CONFIGURATION_REVISION"
|
|
56
|
+
AUDIENCE = "redshift"
|
|
57
|
+
BROKER_TIMEOUT_SECONDS = 20
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RedshiftCredentialError(RuntimeError):
|
|
61
|
+
"""Raised when a Redshift run cannot obtain a database password.
|
|
62
|
+
|
|
63
|
+
Raised rather than allowed to proceed, for two different reasons either side
|
|
64
|
+
of the value. Absent, dlt refuses to configure the destination at all and
|
|
65
|
+
raises `ConfigFieldMissingException` naming
|
|
66
|
+
`DESTINATION__REDSHIFT__CREDENTIALS__PASSWORD` — loud, but pointed at a
|
|
67
|
+
variable no authored pipeline sets, so failing at the mint and naming the
|
|
68
|
+
Broker is the actionable version of the same stop. Empty is the worse case:
|
|
69
|
+
dlt resolves a blank password cleanly and the load reaches Redshift as a
|
|
70
|
+
real login attempt, which is why an empty Broker answer is refused here.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def setup_environment() -> None:
|
|
75
|
+
"""Register the bound Secret Store's provider, then bridge the password.
|
|
76
|
+
|
|
77
|
+
The Secret Store is a Domain property independent of the platform
|
|
78
|
+
(ADR-0022): a Redshift Domain bound to Key Vault resolves its source
|
|
79
|
+
credentials through the same provider a Fabric Domain does, and it must be
|
|
80
|
+
in dlt's chain before the pipeline's first `dlt.secrets` read. A
|
|
81
|
+
`local_toml` store registers nothing (VD-5474).
|
|
82
|
+
"""
|
|
83
|
+
register_secret_provider()
|
|
84
|
+
apply_redshift_environment()
|
|
85
|
+
logger.info("VibeData dlt environment ready (unit=redshift)")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def apply_redshift_environment() -> None:
|
|
89
|
+
"""Mint the database password and set it under dlt's own config name.
|
|
90
|
+
|
|
91
|
+
No-op on any other platform, whose destination is resolved elsewhere.
|
|
92
|
+
"""
|
|
93
|
+
if os.environ.get(PLATFORM_ENV, "").strip() != REDSHIFT_PLATFORM:
|
|
94
|
+
logger.info("Domain does not target Redshift; skipping runtime resolution")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
os.environ[DLT_PASSWORD_ENV] = _mint_password()
|
|
98
|
+
logger.info("Redshift database password resolved for the dlt destination")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _mint_password() -> str:
|
|
102
|
+
"""One Data Platform credential for the acting user, from Studio's Broker.
|
|
103
|
+
|
|
104
|
+
The audience selects the kind; the Broker decides which database it is
|
|
105
|
+
minted against, from the Intent the capability is scoped to. This unit
|
|
106
|
+
never names a database — the ephemeral database name is not usable as an
|
|
107
|
+
input here, and sending one would be a shape the Broker rejects.
|
|
108
|
+
"""
|
|
109
|
+
broker_url = os.environ.get(BROKER_URL_ENV, "").strip().rstrip("/")
|
|
110
|
+
credential_file = os.environ.get(CREDENTIAL_FILE_ENV, "").strip()
|
|
111
|
+
lane = os.environ.get(BROKER_LANE_ENV, "").strip()
|
|
112
|
+
revision_value = os.environ.get(CONFIGURATION_REVISION_ENV, "").strip()
|
|
113
|
+
try:
|
|
114
|
+
configuration_revision = int(revision_value)
|
|
115
|
+
except ValueError:
|
|
116
|
+
configuration_revision = -1
|
|
117
|
+
if (
|
|
118
|
+
not broker_url
|
|
119
|
+
or not credential_file
|
|
120
|
+
or lane not in {"user", "service"}
|
|
121
|
+
or configuration_revision < 0
|
|
122
|
+
):
|
|
123
|
+
raise RedshiftCredentialError(
|
|
124
|
+
"The credential broker URL, credential file, lane, and Domain configuration "
|
|
125
|
+
"revision are required to resolve a Redshift credential. Confirm the "
|
|
126
|
+
"runtime environment is complete."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
bearer = Path(credential_file).read_text(encoding="utf-8").strip()
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
raise RedshiftCredentialError(
|
|
133
|
+
f"Could not read {credential_file!r}. Is the Studio intent session active?"
|
|
134
|
+
) from exc
|
|
135
|
+
if not bearer:
|
|
136
|
+
raise RedshiftCredentialError(f"{credential_file!r} is empty.")
|
|
137
|
+
|
|
138
|
+
request = urllib.request.Request(
|
|
139
|
+
f"{broker_url}/token",
|
|
140
|
+
data=json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"audience": AUDIENCE,
|
|
143
|
+
"scope": "data_platform",
|
|
144
|
+
"lane": lane,
|
|
145
|
+
"configurationRevision": configuration_revision,
|
|
146
|
+
}
|
|
147
|
+
).encode("utf-8"),
|
|
148
|
+
headers={
|
|
149
|
+
"Authorization": f"Bearer {bearer}",
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
method="POST",
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
with urllib.request.urlopen(request, timeout=BROKER_TIMEOUT_SECONDS) as response:
|
|
156
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
157
|
+
except urllib.error.HTTPError as exc:
|
|
158
|
+
raise RedshiftCredentialError(
|
|
159
|
+
f"The credential broker rejected the Redshift request ({exc.code}). "
|
|
160
|
+
"The intent access token may be expired or revoked, or Studio's AWS "
|
|
161
|
+
"identity may lack permission to mint for this database."
|
|
162
|
+
) from exc
|
|
163
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
164
|
+
raise RedshiftCredentialError(
|
|
165
|
+
f"Could not reach the credential broker at {broker_url}/token. Is Studio running?"
|
|
166
|
+
) from exc
|
|
167
|
+
|
|
168
|
+
token = payload.get("access_token")
|
|
169
|
+
if not isinstance(token, str) or not token.strip():
|
|
170
|
+
raise RedshiftCredentialError(
|
|
171
|
+
"The credential broker returned no Redshift database password."
|
|
172
|
+
)
|
|
173
|
+
return token.strip()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@pytest.fixture(autouse=True)
|
|
5
|
+
def clean_env(monkeypatch):
|
|
6
|
+
"""Strip all contract env vars so each test declares exactly what it needs."""
|
|
7
|
+
for var in (
|
|
8
|
+
"VD_DOMAIN_DATA_PLATFORM",
|
|
9
|
+
"VD_DOMAIN_SECRET_STORE_KIND",
|
|
10
|
+
"VD_DOMAIN_SECRET_STORE_LOCATION",
|
|
11
|
+
"VD_CREDENTIAL_BROKER_URL",
|
|
12
|
+
"VD_CREDENTIAL_FILE",
|
|
13
|
+
"VD_CREDENTIAL_LANE",
|
|
14
|
+
"VD_CREDENTIAL_CONFIGURATION_REVISION",
|
|
15
|
+
"DESTINATION__REDSHIFT__CREDENTIALS__PASSWORD",
|
|
16
|
+
):
|
|
17
|
+
monkeypatch.delenv(var, raising=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def redshift_env(monkeypatch, tmp_path):
|
|
22
|
+
"""Minimal valid Redshift intent environment (mirrors build-agent-server-env.ts)."""
|
|
23
|
+
monkeypatch.setenv("VD_DOMAIN_SECRET_STORE_KIND", "local_toml")
|
|
24
|
+
monkeypatch.setenv("VD_DOMAIN_DATA_PLATFORM", "redshift")
|
|
25
|
+
monkeypatch.setenv(
|
|
26
|
+
"VD_CREDENTIAL_BROKER_URL", "http://127.0.0.1:3001/api/v1/credential-broker"
|
|
27
|
+
)
|
|
28
|
+
cred_file = tmp_path / "credential"
|
|
29
|
+
cred_file.write_text("vd_iat_test-intent-token")
|
|
30
|
+
monkeypatch.setenv("VD_CREDENTIAL_FILE", str(cred_file))
|
|
31
|
+
monkeypatch.setenv("VD_CREDENTIAL_LANE", "user")
|
|
32
|
+
monkeypatch.setenv("VD_CREDENTIAL_CONFIGURATION_REVISION", "7")
|
|
33
|
+
return tmp_path
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Redshift database-password resolution for intent-local dlt runs.
|
|
2
|
+
|
|
3
|
+
What this guards: the password is the one destination field that cannot ride the
|
|
4
|
+
Intent environment, because Redshift mints it per invocation against exactly one
|
|
5
|
+
database. Studio publishes the other four coordinates and dlt reads them
|
|
6
|
+
natively, field by field.
|
|
7
|
+
|
|
8
|
+
An absent password is caught by dlt itself, which refuses to configure the
|
|
9
|
+
destination and names the variable. The case that needs guarding here is a
|
|
10
|
+
*blank* one: dlt resolves it cleanly and the load reaches Redshift as a real
|
|
11
|
+
login attempt, so a Broker answer carrying no password is refused rather than
|
|
12
|
+
set.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import urllib.error
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from vibedata.dlt.redshift import bootstrap
|
|
23
|
+
from vibedata.dlt.redshift.bootstrap import (
|
|
24
|
+
DLT_PASSWORD_ENV,
|
|
25
|
+
RedshiftCredentialError,
|
|
26
|
+
apply_redshift_environment,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _broker_response(payload):
|
|
31
|
+
return io.BytesIO(json.dumps(payload).encode("utf-8"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def broker(monkeypatch):
|
|
36
|
+
"""Capture the Broker request and answer it with a password."""
|
|
37
|
+
seen = {}
|
|
38
|
+
|
|
39
|
+
def fake_urlopen(request, timeout=None):
|
|
40
|
+
seen["url"] = request.full_url
|
|
41
|
+
seen["headers"] = dict(request.headers)
|
|
42
|
+
seen["body"] = json.loads(request.data.decode("utf-8"))
|
|
43
|
+
seen["timeout"] = timeout
|
|
44
|
+
return _FakeCtx(_broker_response({"access_token": "minted-password"}))
|
|
45
|
+
|
|
46
|
+
monkeypatch.setattr(bootstrap.urllib.request, "urlopen", fake_urlopen)
|
|
47
|
+
return seen
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _FakeCtx:
|
|
51
|
+
def __init__(self, body):
|
|
52
|
+
self._body = body
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
return self._body
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *_):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestRedshiftDomain:
|
|
62
|
+
def test_puts_the_minted_password_under_dlts_own_config_name(self, redshift_env, broker):
|
|
63
|
+
apply_redshift_environment()
|
|
64
|
+
assert os.environ[DLT_PASSWORD_ENV] == "minted-password"
|
|
65
|
+
|
|
66
|
+
def test_mints_the_redshift_audience_with_the_intent_bearer(self, redshift_env, broker):
|
|
67
|
+
apply_redshift_environment()
|
|
68
|
+
assert broker["url"].endswith("/token")
|
|
69
|
+
assert broker["headers"]["Authorization"] == "Bearer vd_iat_test-intent-token"
|
|
70
|
+
# The Broker decides which database this is for; naming one is a shape
|
|
71
|
+
# it rejects, so the request carries no database.
|
|
72
|
+
assert broker["body"] == {
|
|
73
|
+
"audience": "redshift",
|
|
74
|
+
"scope": "data_platform",
|
|
75
|
+
"lane": "user",
|
|
76
|
+
"configurationRevision": 7,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def test_requires_the_explicit_lane_and_pinned_revision(self, redshift_env, monkeypatch):
|
|
80
|
+
monkeypatch.delenv("VD_CREDENTIAL_LANE")
|
|
81
|
+
with pytest.raises(RedshiftCredentialError):
|
|
82
|
+
apply_redshift_environment()
|
|
83
|
+
|
|
84
|
+
def test_rejects_a_nonsense_revision_rather_than_sending_it(self, redshift_env, monkeypatch):
|
|
85
|
+
monkeypatch.setenv("VD_CREDENTIAL_CONFIGURATION_REVISION", "not-a-number")
|
|
86
|
+
with pytest.raises(RedshiftCredentialError):
|
|
87
|
+
apply_redshift_environment()
|
|
88
|
+
|
|
89
|
+
def test_refuses_an_empty_credential_file(self, redshift_env, monkeypatch):
|
|
90
|
+
(redshift_env / "credential").write_text("")
|
|
91
|
+
with pytest.raises(RedshiftCredentialError):
|
|
92
|
+
apply_redshift_environment()
|
|
93
|
+
|
|
94
|
+
def test_refuses_a_broker_answer_carrying_no_password(self, redshift_env, monkeypatch):
|
|
95
|
+
def no_token(request, timeout=None):
|
|
96
|
+
return _FakeCtx(_broker_response({"access_token": ""}))
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr(bootstrap.urllib.request, "urlopen", no_token)
|
|
99
|
+
with pytest.raises(RedshiftCredentialError):
|
|
100
|
+
apply_redshift_environment()
|
|
101
|
+
|
|
102
|
+
def test_surfaces_a_broker_rejection_rather_than_proceeding(self, redshift_env, monkeypatch):
|
|
103
|
+
def rejected(request, timeout=None):
|
|
104
|
+
raise urllib.error.HTTPError(request.full_url, 403, "Forbidden", {}, None)
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(bootstrap.urllib.request, "urlopen", rejected)
|
|
107
|
+
with pytest.raises(RedshiftCredentialError):
|
|
108
|
+
apply_redshift_environment()
|
|
109
|
+
assert DLT_PASSWORD_ENV not in os.environ
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestOtherPlatforms:
|
|
113
|
+
def test_is_a_no_op_off_redshift(self, monkeypatch):
|
|
114
|
+
monkeypatch.setenv("VD_DOMAIN_DATA_PLATFORM", "motherduck")
|
|
115
|
+
apply_redshift_environment()
|
|
116
|
+
assert DLT_PASSWORD_ENV not in os.environ
|