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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ .pytest_cache/
@@ -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