databricks-tellr 0.3.13.dev4__tar.gz → 0.3.13.dev12__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr
3
- Version: 0.3.13.dev4
3
+ Version: 0.3.13.dev12
4
4
  Summary: Tellr deployment tooling for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -27,6 +27,8 @@ env:
27
27
  value: "${LAKEBASE_INSTANCE}"
28
28
  - name: LAKEBASE_SCHEMA
29
29
  value: "${LAKEBASE_SCHEMA}"
30
+ - name: GOOGLE_OAUTH_ENCRYPTION_KEY
31
+ value: "${GOOGLE_OAUTH_ENCRYPTION_KEY}"
30
32
  - name: LAKEBASE_TYPE
31
33
  value: "${LAKEBASE_TYPE}"
32
34
  - name: LAKEBASE_PG_HOST
@@ -37,6 +39,8 @@ env:
37
39
  value: "${LAKEBASE_ENDPOINT_NAME}"
38
40
  - name: DATABRICKS_HOST
39
41
  valueFrom: "system.databricks_host"
42
+ - name: DATABRICKS_TOKEN
43
+ valueFrom: "system.databricks_token"
40
44
  # Opt-in to trust x-forwarded-email / x-forwarded-user on the MCP endpoint
41
45
  # for app-to-app calls. Safe inside Databricks Apps because the proxy sets
42
46
  # these headers itself and strips any caller-supplied versions. See
@@ -30,8 +30,6 @@ from databricks.sdk.service.apps import (
30
30
  from databricks.sdk.service.database import DatabaseInstance
31
31
  from databricks.sdk.service.workspace import ImportFormat
32
32
 
33
- from databricks_tellr.identifiers import validate_client_id, validate_schema_name
34
-
35
33
  # Autoscaling imports (Lakebase next-gen)
36
34
  try:
37
35
  from databricks.sdk.service.postgres import (
@@ -197,6 +195,7 @@ def create(
197
195
  client: WorkspaceClient | None = None,
198
196
  profile: str | None = None,
199
197
  config_yaml_path: str | None = None,
198
+ encryption_key: str | None = None,
200
199
  mlflow_tracing: dict[str, str] | None = None,
201
200
  ) -> dict[str, Any]:
202
201
  """Deploy Tellr to Databricks Apps.
@@ -226,6 +225,7 @@ def create(
226
225
  client: External WorkspaceClient (optional)
227
226
  profile: Databricks CLI profile name (optional)
228
227
  config_yaml_path: Path to deployment config YAML (mutually exclusive with other args)
228
+ encryption_key: Fernet key for Google OAuth encryption. Auto-generated if not provided.
229
229
  mlflow_tracing: Optional overrides for UC tracing env vars in generated ``app.yaml``.
230
230
  Keys: ``MLFLOW_TRACING_SQL_WAREHOUSE_ID``, ``TELLR_MLFLOW_UC_CATALOG``,
231
231
  ``TELLR_MLFLOW_UC_SCHEMA``, ``TELLR_MLFLOW_UC_TABLE_PREFIX``. With
@@ -258,6 +258,7 @@ def create(
258
258
  profile=profile,
259
259
  config_yaml_path=config_yaml_path,
260
260
  seed_databricks_defaults=False,
261
+ encryption_key=encryption_key,
261
262
  mlflow_tracing=mlflow_tracing,
262
263
  )
263
264
 
@@ -287,9 +288,8 @@ def update(
287
288
  reset_database: If True, drop and recreate the schema (tables recreated on app startup)
288
289
  client: External WorkspaceClient (optional)
289
290
  profile: Databricks CLI profile name (optional)
290
- encryption_key: Override for the legacy Fernet key to relocate into
291
- the encryption_keys table. Default: read from the deployed
292
- app.yaml. The key is no longer written to app.yaml.
291
+ encryption_key: Fernet key for Google OAuth encryption. If not provided, the
292
+ existing key is read from the deployed app.yaml to preserve encrypted data.
293
293
  mlflow_tracing: Optional overrides for UC tracing env vars (same keys as ``create``).
294
294
  Values from deployment YAML are not loaded on update; use this argument or
295
295
  ``TELLR_DEPLOY_MLFLOW_*`` environment variables.
@@ -333,6 +333,7 @@ def _create_databricks(
333
333
  profile: str | None = None,
334
334
  config_yaml_path: str | None = None,
335
335
  seed_databricks_defaults: bool = True,
336
+ encryption_key: str | None = None,
336
337
  mlflow_tracing: dict[str, str] | None = None,
337
338
  ) -> dict[str, Any]:
338
339
  """Deploy Tellr to Databricks Apps with configurable seeding.
@@ -352,6 +353,7 @@ def _create_databricks(
352
353
  profile: Databricks CLI profile name (optional)
353
354
  config_yaml_path: Path to deployment config YAML (mutually exclusive with other args)
354
355
  seed_databricks_defaults: If True, seed Databricks-specific content on startup
356
+ encryption_key: Fernet key for Google OAuth encryption. Auto-generated if not provided.
355
357
  mlflow_tracing: Optional overrides for UC tracing placeholders in ``app.yaml``.
356
358
 
357
359
  Returns:
@@ -415,10 +417,11 @@ def _create_databricks(
415
417
  lakebase_name,
416
418
  schema_name,
417
419
  seed_databricks_defaults=seed_databricks_defaults,
420
+ encryption_key=encryption_key,
418
421
  lakebase_result=lakebase_result,
419
422
  mlflow_tracing=mlflow_subs,
420
423
  )
421
- print(" Generated app.yaml")
424
+ print(" Generated app.yaml (with encryption key)")
422
425
 
423
426
  print(f"Uploading to: {app_file_workspace_path}")
424
427
  _upload_files(ws, staging, app_file_workspace_path)
@@ -504,9 +507,8 @@ def _update_databricks(
504
507
  client: External WorkspaceClient (optional)
505
508
  profile: Databricks CLI profile name (optional)
506
509
  seed_databricks_defaults: If True, seed Databricks-specific content on startup
507
- encryption_key: Override for the legacy Fernet key to relocate into
508
- the encryption_keys table. Default: read from the deployed
509
- app.yaml. The key is no longer written to app.yaml.
510
+ encryption_key: Fernet key for Google OAuth encryption. If not provided, reads
511
+ the existing key from the deployed app.yaml to preserve encrypted data.
510
512
  mlflow_tracing: Optional overrides for UC tracing placeholders in ``app.yaml``.
511
513
 
512
514
  Returns:
@@ -545,28 +547,6 @@ def _update_databricks(
545
547
  print(f" Schema '{schema_name}' reset (tables will be recreated on app startup)")
546
548
  print()
547
549
 
548
- # CRITICAL-3 migration: relocate the legacy app.yaml key into the
549
- # encryption_keys table BEFORE the new (keyless) app.yaml overwrites
550
- # it. Runs at most once per install: after it succeeds, the deployed
551
- # app.yaml has no key entry and encryption_key is None on re-runs.
552
- if encryption_key:
553
- print("Relocating encryption key into Lakebase (encryption_keys)...")
554
- app_for_grant = ws.apps.get(name=app_name)
555
- grant_client_id = _get_app_client_id(app_for_grant)
556
- if not grant_client_id:
557
- print(" Warning: no app client ID — table grant will be skipped")
558
- mig_conn, _ = _get_lakebase_connection(
559
- ws, lakebase_name, lakebase_result=lakebase_result
560
- )
561
- try:
562
- with mig_conn.cursor() as cur:
563
- _migrate_encryption_key_to_lakebase(
564
- cur, schema_name, grant_client_id, encryption_key
565
- )
566
- finally:
567
- mig_conn.close()
568
- print(" Key relocated (relocate, not rotate — no re-encryption)")
569
-
570
550
  # Generate and upload updated files
571
551
  with _staging_dir() as staging:
572
552
  _write_requirements(staging, app_version)
@@ -575,6 +555,7 @@ def _update_databricks(
575
555
  lakebase_name,
576
556
  schema_name,
577
557
  seed_databricks_defaults=seed_databricks_defaults,
558
+ encryption_key=encryption_key,
578
559
  lakebase_result=lakebase_result,
579
560
  mlflow_tracing=mlflow_subs,
580
561
  )
@@ -752,96 +733,32 @@ def _check_breaking_migrations(
752
733
  def _read_existing_encryption_key(
753
734
  ws: WorkspaceClient, workspace_path: str
754
735
  ) -> str | None:
755
- """Read GOOGLE_OAUTH_ENCRYPTION_KEY from an existing deployed app.yaml.
736
+ """Read the GOOGLE_OAUTH_ENCRYPTION_KEY from an existing deployed app.yaml.
756
737
 
757
- Returns the key string, or None when app.yaml is readable but carries no
758
- key entry (already migrated, or a fresh-era install).
738
+ This preserves the encryption key across updates so that previously encrypted
739
+ credentials and tokens remain decryptable.
759
740
 
760
- Raises DeploymentError when app.yaml cannot be downloaded or parsed:
761
- silently treating an unreadable app.yaml as "no key" would skip the
762
- CRITICAL-3 migration and let the new code generate a fresh key,
763
- orphaning all existing ciphertext.
741
+ Returns:
742
+ The encryption key string, or None if not found.
764
743
  """
765
744
  try:
766
745
  resp = ws.workspace.download(f"{workspace_path}/app.yaml")
767
746
  raw = resp.read() if hasattr(resp, "read") else resp
768
747
  content = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
748
+
769
749
  existing = yaml.safe_load(content)
750
+ for env_entry in existing.get("env", []):
751
+ if env_entry.get("name") == "GOOGLE_OAUTH_ENCRYPTION_KEY":
752
+ key = env_entry.get("value")
753
+ if key:
754
+ print(" Preserved existing encryption key from deployed app.yaml")
755
+ return key
770
756
  except Exception as e:
771
- raise DeploymentError(
772
- f"Could not read the deployed app.yaml at {workspace_path}/app.yaml "
773
- f"({e}). Aborting: the update must know whether a legacy encryption "
774
- f"key needs relocating before it overwrites app.yaml."
775
- ) from e
757
+ logger.warning("Could not read existing encryption key from app.yaml: %s", e)
776
758
 
777
- for env_entry in (existing or {}).get("env", []):
778
- if env_entry.get("name") == "GOOGLE_OAUTH_ENCRYPTION_KEY":
779
- key = env_entry.get("value")
780
- if key:
781
- print(" Found legacy encryption key in deployed app.yaml")
782
- return key
783
759
  return None
784
760
 
785
761
 
786
- def _migrate_encryption_key_to_lakebase(
787
- cur: Any,
788
- schema_name: str,
789
- client_id: str | None,
790
- encryption_key: str,
791
- ) -> None:
792
- """Relocate the legacy app.yaml Fernet key into <schema>.encryption_keys.
793
-
794
- SDR-4437 CRITICAL-3, deploy-time migration. Relocate, not rotate: no
795
- re-encryption of existing rows. Idempotent: re-running with the same key
796
- is a no-op. A *different* pre-existing key is a hard error — silently
797
- keeping either key would orphan the ciphertext encrypted under the other.
798
-
799
- The GRANT is unconditional: the original deployer's ALTER DEFAULT
800
- PRIVILEGES attaches to the creating role, so when a different identity
801
- runs the upgrade this explicit grant is the only one that applies.
802
- INSERT is included so the app's boot seed (insert-when-missing branch)
803
- can never be privilege-blocked.
804
-
805
- Takes an open cursor so callers control the endpoint (prod branch,
806
- devloop fork, ...). Caller owns the connection lifecycle.
807
- """
808
- validate_schema_name(schema_name)
809
- if client_id is not None:
810
- validate_client_id(client_id)
811
-
812
- # Matches the SQLAlchemy model (src/database/models/encryption_key.py):
813
- # id INTEGER PK (no autoincrement), key_value TEXT, created_at TIMESTAMP.
814
- cur.execute(
815
- f'CREATE TABLE IF NOT EXISTS "{schema_name}".encryption_keys ('
816
- "id INTEGER PRIMARY KEY, "
817
- "key_value TEXT NOT NULL, "
818
- "created_at TIMESTAMP NOT NULL)"
819
- )
820
- cur.execute(
821
- f'INSERT INTO "{schema_name}".encryption_keys (id, key_value, created_at) '
822
- "VALUES (1, %s, CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING",
823
- (encryption_key,),
824
- )
825
- cur.execute(
826
- f'SELECT key_value FROM "{schema_name}".encryption_keys WHERE id = 1'
827
- )
828
- row = cur.fetchone()
829
- if not row or row[0] != encryption_key:
830
- raise DeploymentError(
831
- "encryption_keys already holds a DIFFERENT key than the deployed "
832
- "app.yaml. Refusing to continue: proceeding would orphan ciphertext "
833
- "encrypted under one of the two keys. Determine which key decrypts "
834
- "the existing google_oauth_tokens/google_global_credentials rows, "
835
- "fix the encryption_keys row (or the app.yaml) to match, then "
836
- "re-run the update. See SDR-4437 PR-3 design."
837
- )
838
- if client_id:
839
- cur.execute(
840
- f'GRANT SELECT, INSERT ON "{schema_name}".encryption_keys '
841
- f'TO "{client_id}"'
842
- )
843
-
844
-
845
762
  def _load_deployment_config(config_yaml_path: str) -> dict[str, str]:
846
763
  """Load deployment settings from config/deployment.yaml-style files.
847
764
 
@@ -1360,19 +1277,19 @@ def _write_app_yaml(
1360
1277
  lakebase_name: str,
1361
1278
  schema_name: str,
1362
1279
  seed_databricks_defaults: bool = False,
1280
+ encryption_key: str | None = None,
1363
1281
  lakebase_result: dict[str, Any] | None = None,
1364
1282
  mlflow_tracing: dict[str, str] | None = None,
1365
1283
  ) -> None:
1366
1284
  """Generate app.yaml with environment variables.
1367
1285
 
1368
- The Fernet encryption key is deliberately NOT written here (SDR-4437
1369
- CRITICAL-3): the app reads/seeds it from the encryption_keys table.
1370
-
1371
1286
  Args:
1372
1287
  staging_dir: Directory to write the app.yaml file
1373
1288
  lakebase_name: Lakebase instance name
1374
1289
  schema_name: Schema name
1375
1290
  seed_databricks_defaults: If True, include Databricks-specific content seeding
1291
+ encryption_key: Fernet encryption key for Google OAuth credentials/tokens.
1292
+ Auto-generated if not provided.
1376
1293
  lakebase_result: Result dict from _get_or_create_lakebase() with type info.
1377
1294
  mlflow_tracing: Resolved template keys for UC tracing (four entries). If
1378
1295
  omitted, values are taken only from ``TELLR_DEPLOY_MLFLOW_*`` env vars.
@@ -1383,6 +1300,12 @@ def _write_app_yaml(
1383
1300
  else:
1384
1301
  init_call = "init_database()"
1385
1302
 
1303
+ if not encryption_key:
1304
+ from cryptography.fernet import Fernet
1305
+
1306
+ encryption_key = Fernet.generate_key().decode()
1307
+ logger.info("Auto-generated GOOGLE_OAUTH_ENCRYPTION_KEY for deployment")
1308
+
1386
1309
  # Determine lakebase type info for env vars
1387
1310
  lakebase_type = (lakebase_result or {}).get("type", "provisioned")
1388
1311
  lakebase_pg_host = (lakebase_result or {}).get("host", "")
@@ -1397,6 +1320,7 @@ def _write_app_yaml(
1397
1320
  LAKEBASE_INSTANCE=lakebase_name,
1398
1321
  LAKEBASE_SCHEMA=schema_name,
1399
1322
  INIT_DATABASE_CALL=init_call,
1323
+ GOOGLE_OAUTH_ENCRYPTION_KEY=encryption_key,
1400
1324
  LAKEBASE_TYPE=lakebase_type,
1401
1325
  LAKEBASE_PG_HOST=lakebase_pg_host,
1402
1326
  LAKEBASE_PROJECT_ID=lakebase_project_id,
@@ -1643,9 +1567,6 @@ def _setup_database_schema(
1643
1567
  print(" Warning: Could not get app client ID - schema setup skipped")
1644
1568
  return
1645
1569
 
1646
- validate_schema_name(schema_name)
1647
- validate_client_id(client_id)
1648
-
1649
1570
  conn, _ = _get_lakebase_connection(ws, lakebase_name, lakebase_result=lakebase_result)
1650
1571
 
1651
1572
  try:
@@ -1676,10 +1597,6 @@ def _reset_schema(
1676
1597
  """
1677
1598
  client_id = _get_app_client_id(app)
1678
1599
 
1679
- validate_schema_name(schema_name)
1680
- if client_id:
1681
- validate_client_id(client_id)
1682
-
1683
1600
  conn, _ = _get_lakebase_connection(ws, lakebase_name, lakebase_result=lakebase_result)
1684
1601
 
1685
1602
  try:
@@ -1707,9 +1624,6 @@ def _grant_schema_permissions(cur: Any, schema_name: str, client_id: str) -> Non
1707
1624
 
1708
1625
  This function only grants schema/table permissions — it does NOT create roles.
1709
1626
  """
1710
- validate_schema_name(schema_name)
1711
- validate_client_id(client_id)
1712
-
1713
1627
  # Verify the role exists before granting
1714
1628
  cur.execute(
1715
1629
  "SELECT 1 FROM pg_roles WHERE rolname = %s", (client_id,)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr
3
- Version: 0.3.13.dev4
3
+ Version: 0.3.13.dev12
4
4
  Summary: Tellr deployment tooling for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -2,7 +2,6 @@ README.md
2
2
  pyproject.toml
3
3
  databricks_tellr/__init__.py
4
4
  databricks_tellr/deploy.py
5
- databricks_tellr/identifiers.py
6
5
  databricks_tellr.egg-info/PKG-INFO
7
6
  databricks_tellr.egg-info/SOURCES.txt
8
7
  databricks_tellr.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databricks-tellr"
7
- version = "0.3.13.dev4"
7
+ version = "0.3.13.dev12"
8
8
  description = "Tellr deployment tooling for Databricks Apps"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,37 +0,0 @@
1
- """Validation for identifiers interpolated into Postgres DDL (SDR-4437 MEDIUM-5).
2
-
3
- Both inputs are config/platform-derived today (schema_name from deploy
4
- config, client_id from the app SP), so this is hardening against future
5
- user-derived values, not a live injection.
6
-
7
- Lives only in this (deploy-tool) distribution: the app distribution has no
8
- DDL-identifier interpolation site once the dead setup_lakebase_schema is
9
- removed, so there is no counterpart to keep in sync.
10
- """
11
-
12
- import re
13
-
14
- _SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
15
- # UUID (App SP client id) OR all-digits (str(service_principal_id) fallback
16
- # in _get_app_client_id). Both are injection-safe charsets.
17
- _CLIENT_ID_RE = re.compile(
18
- r"^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
19
- r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9]+)$"
20
- )
21
-
22
-
23
- def validate_schema_name(schema: str) -> str:
24
- """Return *schema* if it is a safe Postgres schema identifier; else raise."""
25
- if not isinstance(schema, str) or not _SCHEMA_RE.match(schema):
26
- raise ValueError(f"Invalid Postgres schema name: {schema!r}")
27
- return schema
28
-
29
-
30
- def validate_client_id(client_id: str) -> str:
31
- """Return *client_id* if it is a UUID or numeric SP id; else raise."""
32
- if not isinstance(client_id, str) or not _CLIENT_ID_RE.match(client_id):
33
- raise ValueError(
34
- f"Invalid service-principal client id (expected UUID or numeric id): "
35
- f"{client_id!r}"
36
- )
37
- return client_id