databricks-tellr 0.4.3.dev12__tar.gz → 0.4.3.dev13__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.
Files changed (15) hide show
  1. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/PKG-INFO +2 -1
  2. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/_templates/app.yaml.template +1 -0
  3. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/deploy.py +160 -6
  4. databricks_tellr-0.4.3.dev13/databricks_tellr/secret_key.py +336 -0
  5. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/PKG-INFO +2 -1
  6. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/SOURCES.txt +1 -0
  7. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/requires.txt +1 -0
  8. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/pyproject.toml +2 -1
  9. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/README.md +0 -0
  10. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/__init__.py +0 -0
  11. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/_templates/requirements.txt.template +0 -0
  12. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/identifiers.py +0 -0
  13. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/dependency_links.txt +0 -0
  14. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/top_level.txt +0 -0
  15. {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/setup.cfg +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr
3
- Version: 0.4.3.dev12
3
+ Version: 0.4.3.dev13
4
4
  Summary: Tellr deployment tooling for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: databricks-sdk>=0.85.0
8
8
  Requires-Dist: psycopg2-binary>=2.9.0
9
9
  Requires-Dist: pyyaml>=6.0.0
10
+ Requires-Dist: requests>=2.31.0
10
11
 
11
12
  # databricks-tellr
12
13
 
@@ -67,3 +67,4 @@ env:
67
67
  # Set to "0" if you want to force-disable the fast path entirely.
68
68
  - name: HUASHU_PIPELINE_ENABLED
69
69
  value: "1"
70
+ ${ENCRYPTION_SECRET_ENV_BLOCK}
@@ -30,6 +30,7 @@ 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 import secret_key
33
34
  from databricks_tellr.identifiers import validate_client_id, validate_schema_name
34
35
 
35
36
  # Autoscaling imports (Lakebase next-gen)
@@ -198,6 +199,8 @@ def create(
198
199
  profile: str | None = None,
199
200
  config_yaml_path: str | None = None,
200
201
  mlflow_tracing: dict[str, str] | None = None,
202
+ encryption_secret_scope: str | None = None,
203
+ encryption_secret_key: str = secret_key.DEFAULT_SECRET_KEY,
201
204
  ) -> dict[str, Any]:
202
205
  """Deploy Tellr to Databricks Apps.
203
206
 
@@ -232,6 +235,11 @@ def create(
232
235
  ``config_yaml_path``, YAML ``mlflow_tracing`` applies first; non-empty
233
236
  entries here override. Empty slots can be filled from deploy-time env
234
237
  vars ``TELLR_DEPLOY_MLFLOW_*``.
238
+ encryption_secret_scope: Opt in to the secret-backed Fernet key. When set,
239
+ the key is stored in this Databricks secret scope and attached to the
240
+ app as a secret resource instead of living in the encryption_keys
241
+ Lakebase table. When omitted, the Lakebase-backed path is used.
242
+ encryption_secret_key: Secret key name within that scope.
235
243
 
236
244
  Returns:
237
245
  Dictionary with deployment info:
@@ -259,6 +267,8 @@ def create(
259
267
  config_yaml_path=config_yaml_path,
260
268
  seed_databricks_defaults=False,
261
269
  mlflow_tracing=mlflow_tracing,
270
+ encryption_secret_scope=encryption_secret_scope,
271
+ encryption_secret_key=encryption_secret_key,
262
272
  )
263
273
 
264
274
 
@@ -273,6 +283,8 @@ def update(
273
283
  profile: str | None = None,
274
284
  encryption_key: str | None = None,
275
285
  mlflow_tracing: dict[str, str] | None = None,
286
+ encryption_secret_scope: str | None = None,
287
+ encryption_secret_key: str = secret_key.DEFAULT_SECRET_KEY,
276
288
  ) -> dict[str, Any]:
277
289
  """Deploy a new version of an existing Tellr app.
278
290
 
@@ -293,6 +305,11 @@ def update(
293
305
  mlflow_tracing: Optional overrides for UC tracing env vars (same keys as ``create``).
294
306
  Values from deployment YAML are not loaded on update; use this argument or
295
307
  ``TELLR_DEPLOY_MLFLOW_*`` environment variables.
308
+ encryption_secret_scope: Databricks secret scope that holds the Fernet
309
+ master key (opt-in secret mode). When set, the key is stored in and
310
+ read from this scope rather than Lakebase.
311
+ encryption_secret_key: Name of the secret within *encryption_secret_scope*.
312
+ Defaults to ``DEFAULT_SECRET_KEY`` ("tellr-encryption-key").
296
313
 
297
314
  Returns:
298
315
  Dictionary with deployment info
@@ -312,6 +329,8 @@ def update(
312
329
  seed_databricks_defaults=False,
313
330
  encryption_key=encryption_key,
314
331
  mlflow_tracing=mlflow_tracing,
332
+ encryption_secret_scope=encryption_secret_scope,
333
+ encryption_secret_key=encryption_secret_key,
315
334
  )
316
335
 
317
336
 
@@ -334,6 +353,8 @@ def _create_databricks(
334
353
  config_yaml_path: str | None = None,
335
354
  seed_databricks_defaults: bool = True,
336
355
  mlflow_tracing: dict[str, str] | None = None,
356
+ encryption_secret_scope: str | None = None,
357
+ encryption_secret_key: str = secret_key.DEFAULT_SECRET_KEY,
337
358
  ) -> dict[str, Any]:
338
359
  """Deploy Tellr to Databricks Apps with configurable seeding.
339
360
 
@@ -377,6 +398,25 @@ def _create_databricks(
377
398
  app_file_workspace_path = config.get("app_file_workspace_path")
378
399
  lakebase_compute = config.get("lakebase_compute", lakebase_compute)
379
400
  app_compute = config.get("app_compute", app_compute)
401
+ encryption_secret_scope = encryption_secret_scope or config.get(
402
+ "encryption_secret_scope"
403
+ )
404
+ encryption_secret_key = (
405
+ config.get("encryption_secret_key") or encryption_secret_key
406
+ )
407
+
408
+ # Preflight before anything is created, so a failure leaves nothing behind.
409
+ resolved_key: str | None = None
410
+ if encryption_secret_scope:
411
+ print(f"Secret-backed encryption key: {encryption_secret_scope}/{encryption_secret_key}")
412
+ secret_key.preflight_scope(ws, encryption_secret_scope)
413
+ resolved_key = secret_key.resolve_key_for_create(
414
+ ws, encryption_secret_scope, encryption_secret_key
415
+ )
416
+
417
+ encryption_secret_resource_key = (
418
+ secret_key.RESOURCE_KEY if encryption_secret_scope else None
419
+ )
380
420
 
381
421
  mlflow_subs = _mlflow_substitutions_for_app_yaml(
382
422
  deployment_flat=deployment_flat_for_mlflow,
@@ -417,6 +457,7 @@ def _create_databricks(
417
457
  seed_databricks_defaults=seed_databricks_defaults,
418
458
  lakebase_result=lakebase_result,
419
459
  mlflow_tracing=mlflow_subs,
460
+ encryption_secret_resource_key=encryption_secret_resource_key,
420
461
  )
421
462
  print(" Generated app.yaml")
422
463
 
@@ -435,6 +476,8 @@ def _create_databricks(
435
476
  compute_size=app_compute,
436
477
  lakebase_name=lakebase_name,
437
478
  lakebase_type=lakebase_type,
479
+ encryption_secret_scope=encryption_secret_scope,
480
+ encryption_secret_key=encryption_secret_key,
438
481
  )
439
482
  print(" App registered")
440
483
  print()
@@ -489,9 +532,11 @@ def _update_databricks(
489
532
  seed_databricks_defaults: bool = True,
490
533
  encryption_key: str | None = None,
491
534
  mlflow_tracing: dict[str, str] | None = None,
535
+ encryption_secret_scope: str | None = None,
536
+ encryption_secret_key: str = secret_key.DEFAULT_SECRET_KEY,
492
537
  ) -> dict[str, Any]:
493
538
  """Deploy a new version of an existing Tellr app with configurable seeding.
494
-
539
+
495
540
  Internal function with full control over seeding behavior.
496
541
 
497
542
  Args:
@@ -508,6 +553,11 @@ def _update_databricks(
508
553
  the encryption_keys table. Default: read from the deployed
509
554
  app.yaml. The key is no longer written to app.yaml.
510
555
  mlflow_tracing: Optional overrides for UC tracing placeholders in ``app.yaml``.
556
+ encryption_secret_scope: Databricks secret scope that holds the Fernet
557
+ master key (opt-in secret mode). When set, the key is stored in and
558
+ read from this scope rather than Lakebase.
559
+ encryption_secret_key: Name of the secret within *encryption_secret_scope*.
560
+ Defaults to ``DEFAULT_SECRET_KEY`` ("tellr-encryption-key").
511
561
 
512
562
  Returns:
513
563
  Dictionary with deployment info
@@ -519,6 +569,24 @@ def _update_databricks(
519
569
 
520
570
  ws = _get_workspace_client(client, profile)
521
571
 
572
+ app_already_secret = secret_key.app_is_secret_mode(ws, app_name)
573
+ if not encryption_secret_scope and app_already_secret:
574
+ if encryption_key:
575
+ raise DeploymentError(
576
+ f"App {app_name} uses a secret-backed encryption key, so passing "
577
+ f"encryption_key would recreate the Lakebase key row this app was "
578
+ f"migrated off — and a mismatched value would silently orphan "
579
+ f"stored credentials. Re-run with "
580
+ f"encryption_secret_scope=... instead."
581
+ )
582
+ print(" Secret-backed encryption key retained (app resource unchanged)")
583
+
584
+ encryption_secret_resource_key = (
585
+ secret_key.RESOURCE_KEY
586
+ if (encryption_secret_scope or app_already_secret)
587
+ else None
588
+ )
589
+
522
590
  mlflow_subs = _mlflow_substitutions_for_app_yaml(
523
591
  deployment_flat={},
524
592
  overrides=mlflow_tracing,
@@ -545,11 +613,32 @@ def _update_databricks(
545
613
  print(f" Schema '{schema_name}' reset (tables will be recreated on app startup)")
546
614
  print()
547
615
 
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:
616
+ if encryption_secret_scope:
617
+ # Secret mode: relocate into the secret; never seed Lakebase.
618
+ validate_schema_name(schema_name) # defense-in-depth: interpolated into SQL below
619
+ secret_key.preflight_scope(ws, encryption_secret_scope)
620
+ mig_conn, _ = _get_lakebase_connection(
621
+ ws, lakebase_name, lakebase_result=lakebase_result
622
+ )
623
+ try:
624
+ with mig_conn.cursor() as cur:
625
+ secret_key.preflight_lakebase_privileges(cur, schema_name)
626
+ lakebase_key = secret_key.read_lakebase_key(cur, schema_name)
627
+ finally:
628
+ mig_conn.close()
629
+
630
+ _, wrote = secret_key.resolve_key_for_update(
631
+ ws, encryption_secret_scope, encryption_secret_key,
632
+ lakebase_key, encryption_key,
633
+ )
634
+ if wrote:
635
+ print(" Key written to the secret and verified")
636
+ secret_key.attach_secret_resource(
637
+ ws, app_name, encryption_secret_scope, encryption_secret_key
638
+ )
639
+ print(" Secret resource attached")
640
+ elif encryption_key:
641
+ # Legacy CRITICAL-3 relocation into Lakebase — unchanged.
553
642
  print("Relocating encryption key into Lakebase (encryption_keys)...")
554
643
  app_for_grant = ws.apps.get(name=app_name)
555
644
  grant_client_id = _get_app_client_id(app_for_grant)
@@ -577,6 +666,7 @@ def _update_databricks(
577
666
  seed_databricks_defaults=seed_databricks_defaults,
578
667
  lakebase_result=lakebase_result,
579
668
  mlflow_tracing=mlflow_subs,
669
+ encryption_secret_resource_key=encryption_secret_resource_key,
580
670
  )
581
671
  _upload_files(ws, staging, app_file_workspace_path)
582
672
  print(" Files updated")
@@ -591,6 +681,35 @@ def _update_databricks(
591
681
  if app.url:
592
682
  print(f" URL: {app.url}")
593
683
 
684
+ if encryption_secret_scope:
685
+ if not app.url:
686
+ print(" WARNING: no app URL — cannot confirm the key source; "
687
+ "leaving the Lakebase key row in place")
688
+ elif secret_key.app_reports_secret_source(ws, app.url):
689
+ del_conn = None
690
+ try:
691
+ del_conn, _ = _get_lakebase_connection(
692
+ ws, lakebase_name, lakebase_result=lakebase_result
693
+ )
694
+ with del_conn.cursor() as cur:
695
+ if secret_key.read_lakebase_key(cur, schema_name):
696
+ secret_key.delete_lakebase_key_row(cur, schema_name)
697
+ print(" Lakebase key row deleted — the secret is now "
698
+ "the only copy")
699
+ else:
700
+ print(" No Lakebase key row to remove")
701
+ except Exception as exc: # noqa: BLE001 — deploy already succeeded
702
+ print(f" WARNING: could not delete the Lakebase key row: {exc}")
703
+ print(f' Run manually: DELETE FROM "{schema_name}".'
704
+ f"encryption_keys WHERE id = 1;")
705
+ finally:
706
+ if del_conn is not None:
707
+ del_conn.close()
708
+ else:
709
+ print(" WARNING: the deployed app does not report the secret as "
710
+ "its key source. Leaving the Lakebase key row in place. This "
711
+ "is expected if the app version predates secret-mode support.")
712
+
594
713
  return {
595
714
  "url": app.url,
596
715
  "app_name": app_name,
@@ -616,6 +735,12 @@ def delete(
616
735
 
617
736
  Note: This does not delete the Lakebase instance by default.
618
737
 
738
+ Deliberately does NOT touch secrets or secret scopes. A devloop fork shares
739
+ the production app's secret scope and key by design (so it can decrypt
740
+ inherited ciphertext), which means deleting the secret here would destroy
741
+ production's Fernet master key during a fork teardown. Only the app and its
742
+ Lakebase branch are removed. Do not "clean up" the secret here.
743
+
619
744
  Args:
620
745
  app_name: Name of the app to delete
621
746
  lakebase_name: Lakebase instance name (required if reset_database=True)
@@ -888,6 +1013,8 @@ def _load_deployment_config(config_yaml_path: str) -> dict[str, str]:
888
1013
  "lakebase_name": lakebase_config.get("database_name"),
889
1014
  "schema_name": lakebase_config.get("schema"),
890
1015
  "lakebase_compute": lakebase_config.get("capacity"),
1016
+ "encryption_secret_scope": env_config.get("encryption_secret_scope"),
1017
+ "encryption_secret_key": env_config.get("encryption_secret_key"),
891
1018
  **ml_flat,
892
1019
  }
893
1020
 
@@ -1367,6 +1494,7 @@ def _write_app_yaml(
1367
1494
  seed_databricks_defaults: bool = False,
1368
1495
  lakebase_result: dict[str, Any] | None = None,
1369
1496
  mlflow_tracing: dict[str, str] | None = None,
1497
+ encryption_secret_resource_key: str | None = None,
1370
1498
  ) -> None:
1371
1499
  """Generate app.yaml with environment variables.
1372
1500
 
@@ -1381,6 +1509,11 @@ def _write_app_yaml(
1381
1509
  lakebase_result: Result dict from _get_or_create_lakebase() with type info.
1382
1510
  mlflow_tracing: Resolved template keys for UC tracing (four entries). If
1383
1511
  omitted, values are taken only from ``TELLR_DEPLOY_MLFLOW_*`` env vars.
1512
+ encryption_secret_resource_key: When set, add an ``env:`` entry mapping
1513
+ this Apps secret resource key into the environment. The resource
1514
+ declaration alone does not inject anything — the ``valueFrom`` entry
1515
+ is required (verified live). No key material is written; this is a
1516
+ resource reference only. Leave None for the Lakebase-backed path.
1384
1517
  """
1385
1518
  # Build init_database call - only show seed_databricks_defaults when True
1386
1519
  if seed_databricks_defaults:
@@ -1397,6 +1530,15 @@ def _write_app_yaml(
1397
1530
  if mlflow_tracing is None:
1398
1531
  mlflow_tracing = _mlflow_substitutions_for_app_yaml()
1399
1532
 
1533
+ if encryption_secret_resource_key:
1534
+ # The Apps secret resource supplies the value; this only names it.
1535
+ secret_env_block = (
1536
+ f" - name: {encryption_secret_resource_key}\n"
1537
+ f' valueFrom: "{encryption_secret_resource_key}"\n'
1538
+ )
1539
+ else:
1540
+ secret_env_block = ""
1541
+
1400
1542
  template_content = _load_template("app.yaml.template")
1401
1543
  content = Template(template_content).substitute(
1402
1544
  LAKEBASE_INSTANCE=lakebase_name,
@@ -1414,6 +1556,7 @@ def _write_app_yaml(
1414
1556
  TELLR_MLFLOW_UC_TABLE_PREFIX=mlflow_tracing.get(
1415
1557
  "TELLR_MLFLOW_UC_TABLE_PREFIX", ""
1416
1558
  ),
1559
+ ENCRYPTION_SECRET_ENV_BLOCK=secret_env_block,
1417
1560
  )
1418
1561
  (staging_dir / "app.yaml").write_text(content)
1419
1562
 
@@ -1485,6 +1628,8 @@ def _create_app(
1485
1628
  compute_size: str,
1486
1629
  lakebase_name: str,
1487
1630
  lakebase_type: str = "provisioned",
1631
+ encryption_secret_scope: str | None = None,
1632
+ encryption_secret_key: str | None = None,
1488
1633
  ) -> App:
1489
1634
  """Create Databricks App with database resource (without deploying).
1490
1635
 
@@ -1514,6 +1659,15 @@ def _create_app(
1514
1659
  app_resources = []
1515
1660
  logger.info("Autoscaling mode: skipping AppResourceDatabase (using env vars)")
1516
1661
 
1662
+ # Both branches: autoscaling builds an empty resource list, so appending
1663
+ # after the branch covers provisioned and autoscaling alike.
1664
+ if encryption_secret_scope:
1665
+ app_resources.append(
1666
+ secret_key.build_secret_resource(
1667
+ encryption_secret_scope, encryption_secret_key
1668
+ )
1669
+ )
1670
+
1517
1671
  app = App(
1518
1672
  name=app_name,
1519
1673
  description=description,
@@ -0,0 +1,336 @@
1
+ """Secret-backed Fernet master key helpers for the Tellr deploy tool.
2
+
3
+ The Lakebase-backed path (SDR-4437 CRITICAL-3) remains the default; everything
4
+ here serves the opt-in path that stores the key in a Databricks secret and
5
+ attaches it to the app as a secret resource. Design and the live-verified
6
+ platform behaviour this relies on:
7
+ docs/superpowers/specs/2026-08-20-secret-backed-fernet-key-design.md
8
+
9
+ Deliberately a separate module: deploy.py is already very large, and keeping
10
+ these helpers here makes them unit-testable without mocking a whole deployment.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import logging
17
+ import time
18
+ from typing import Any
19
+
20
+ import requests
21
+ from cryptography.fernet import Fernet
22
+ from databricks.sdk.service.apps import (
23
+ App,
24
+ AppResource,
25
+ AppResourceSecret,
26
+ AppResourceSecretSecretPermission,
27
+ )
28
+ from databricks.sdk.service.workspace import ScopeBackendType
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ #: Apps secret-resource key, and therefore the injected env var name. Verified
33
+ #: that uppercase-underscore resource keys are accepted.
34
+ RESOURCE_KEY = "TELLR_ENCRYPTION_KEY"
35
+
36
+ #: Default secret key name. Spaces are actually accepted by the API, but a
37
+ #: hyphenated name keeps it consistent with the resource key.
38
+ DEFAULT_SECRET_KEY = "tellr-encryption-key"
39
+
40
+
41
+ class SecretKeyError(Exception):
42
+ """Raised for any recoverable secret-mode setup failure."""
43
+
44
+
45
+ def preflight_scope(ws: Any, scope: str) -> None:
46
+ """Ensure *scope* exists and is usable, before any key material is written.
47
+
48
+ Runs before the app is created or updated so a failure leaves nothing
49
+ half-done. Creates the scope when absent, with ``initial_manage_principal``
50
+ omitted so the creator is sole manager — explicitly NOT ``"users"``, which
51
+ would grant every workspace user MANAGE on the scope holding the key.
52
+ """
53
+ existing = {s.name: s for s in ws.secrets.list_scopes()}
54
+ if scope in existing:
55
+ backend = existing[scope].backend_type
56
+ if backend is not None and backend != ScopeBackendType.DATABRICKS:
57
+ raise SecretKeyError(
58
+ f"Secret scope '{scope}' is {backend} backed. Secret mode writes "
59
+ f"the key and reads it back to verify; Key Vault backed scopes are "
60
+ f"written and read through Key Vault, not this API. Use a "
61
+ f"Databricks-backed scope."
62
+ )
63
+ return
64
+
65
+ try:
66
+ ws.secrets.create_scope(scope=scope)
67
+ except Exception as exc: # noqa: BLE001 — mapped to actionable guidance below
68
+ text = str(exc).upper()
69
+ manual = f"databricks secrets create-scope {scope}"
70
+ if "LIMIT" in text:
71
+ raise SecretKeyError(
72
+ f"Cannot create secret scope '{scope}': this workspace is at its "
73
+ f"secret-scope limit (1,000 by default, raisable on request). Pass "
74
+ f"an existing scope via encryption_secret_scope instead."
75
+ ) from exc
76
+ if "UNAUTHORIZED" in text or "PERMISSION" in text or "BAD_REQUEST" in text:
77
+ raise SecretKeyError(
78
+ f"Not permitted to create secret scope '{scope}'. Ask a workspace "
79
+ f"admin to run `{manual}` and grant you MANAGE on it, then re-run."
80
+ ) from exc
81
+ raise SecretKeyError(
82
+ f"Could not create secret scope '{scope}': {exc}. Create it manually "
83
+ f"with `{manual}` and re-run."
84
+ ) from exc
85
+ logger.info("Created secret scope %s (creator is sole manager)", scope)
86
+
87
+
88
+ def preflight_lakebase_privileges(cur: Any, schema_name: str) -> None:
89
+ """Verify the deploying human can read and delete the encryption_keys row.
90
+
91
+ On installs where the app SP created the table and ``REASSIGN OWNED`` rehomed
92
+ it to ``tellr_app_owners``, a deployer outside ``databricks_superuser`` has
93
+ neither privilege. Without this probe a denied SELECT would be indistinguishable
94
+ from "no row", and the relocate ladder would mint a fresh key over live
95
+ ciphertext.
96
+ """
97
+ table = f"{schema_name}.encryption_keys"
98
+ missing = []
99
+ for priv in ("SELECT", "DELETE"):
100
+ cur.execute(
101
+ "SELECT has_table_privilege(current_user, %s, %s)", (table, priv)
102
+ )
103
+ row = cur.fetchone()
104
+ if not row or not row[0]:
105
+ missing.append(priv)
106
+ if missing:
107
+ raise SecretKeyError(
108
+ f"The deploying identity lacks {' and '.join(missing)} on {table}, so "
109
+ f"the key cannot be safely relocated out of Lakebase. Grant those "
110
+ f"privileges (or run the deploy as an identity in databricks_superuser) "
111
+ f"and re-run. Nothing has been changed."
112
+ )
113
+
114
+
115
+ def _looks_absent(exc: Exception) -> bool:
116
+ return "RESOURCE_DOES_NOT_EXIST" in str(exc).upper()
117
+
118
+
119
+ def read_secret_key(ws: Any, scope: str, key: str) -> str | None:
120
+ """Return the Fernet key stored at *(scope, key)*, or None if truly absent.
121
+
122
+ ``GetSecretResponse.value`` is base64 (the API returns the value "in its byte
123
+ representation"), so it must be decoded. Any error other than a genuine
124
+ not-found is raised: treating a permission denial as absence would let the
125
+ caller overwrite a secret that is already protecting live ciphertext.
126
+ """
127
+ try:
128
+ resp = ws.secrets.get_secret(scope=scope, key=key)
129
+ except Exception as exc: # noqa: BLE001
130
+ if _looks_absent(exc):
131
+ return None
132
+ raise SecretKeyError(
133
+ f"Could not read secret {scope}/{key}: {exc}. This is not a "
134
+ f"'not found' error, so it is most likely a permission problem — "
135
+ f"refusing to continue rather than risk overwriting a key that may "
136
+ f"already protect stored credentials."
137
+ ) from exc
138
+
139
+ if resp is None or not resp.value:
140
+ return None
141
+ value = base64.b64decode(resp.value).decode()
142
+ try:
143
+ Fernet(value.encode())
144
+ except (ValueError, TypeError) as exc:
145
+ raise SecretKeyError(
146
+ f"Secret {scope}/{key} exists but is not a valid Fernet key. Refusing "
147
+ f"to use or overwrite it — inspect it manually and resolve."
148
+ ) from exc
149
+ return value
150
+
151
+
152
+ def write_and_verify_secret_key(ws: Any, scope: str, key: str, value: str) -> None:
153
+ """Write *value* to *(scope, key)* and confirm it reads back identically.
154
+
155
+ The read-back is the gate that later permits deleting the Lakebase row: no
156
+ key material is removed from Lakebase until the secret has been proven
157
+ present and correct.
158
+ """
159
+ ws.secrets.put_secret(scope=scope, key=key, string_value=value)
160
+ stored = read_secret_key(ws, scope, key)
161
+ if stored != value:
162
+ raise SecretKeyError(
163
+ f"Secret {scope}/{key} failed read-back verification after write. "
164
+ f"The Lakebase key row has NOT been touched. Resolve the secret "
165
+ f"store problem and re-run."
166
+ )
167
+ logger.info("Secret %s/%s written and verified", scope, key)
168
+
169
+
170
+ def build_secret_resource(scope: str, key: str) -> AppResource:
171
+ """Build the Apps secret resource that injects the key.
172
+
173
+ No ``put_acl`` is needed anywhere: attaching this resource auto-grants the
174
+ app's service principal READ on the scope (verified live). Adding one would
175
+ require MANAGE on the scope, which is what made fork creation unworkable.
176
+ """
177
+ return AppResource(
178
+ name=RESOURCE_KEY,
179
+ secret=AppResourceSecret(
180
+ scope=scope, key=key,
181
+ permission=AppResourceSecretSecretPermission.READ,
182
+ ),
183
+ )
184
+
185
+
186
+ def resolve_key_for_create(ws: Any, scope: str, key: str) -> str:
187
+ """Return the key a fresh install should use, writing one if none exists."""
188
+ existing = read_secret_key(ws, scope, key)
189
+ if existing:
190
+ logger.info("Reusing the existing Fernet key at %s/%s", scope, key)
191
+ return existing
192
+ generated = Fernet.generate_key().decode()
193
+ write_and_verify_secret_key(ws, scope, key, generated)
194
+ return generated
195
+
196
+
197
+ def read_lakebase_key(cur: Any, schema_name: str) -> str | None:
198
+ """Read the existing Fernet key row, failing closed on anything but absence.
199
+
200
+ Only a genuinely missing table or row yields None. A permission error must
201
+ abort: on installs where the app SP owns the table, a deployer outside
202
+ ``databricks_superuser`` gets denied, and treating that as "no row" would walk
203
+ the ladder to case 4 and mint a fresh key over live ciphertext.
204
+ """
205
+ try:
206
+ cur.execute(
207
+ f'SELECT key_value FROM "{schema_name}".encryption_keys WHERE id = 1'
208
+ )
209
+ except Exception as exc: # noqa: BLE001
210
+ text = str(exc).lower()
211
+ if "does not exist" in text and "relation" in text:
212
+ return None
213
+ raise SecretKeyError(
214
+ f"Could not read the existing key from {schema_name}.encryption_keys: "
215
+ f"{exc}. This is not a missing-table error, so it is most likely a "
216
+ f"permission problem. Refusing to continue — proceeding could generate "
217
+ f"a fresh key and orphan every stored Google credential."
218
+ ) from exc
219
+ row = cur.fetchone()
220
+ return row[0] if row and row[0] else None
221
+
222
+
223
+ def resolve_key_for_update(
224
+ ws: Any,
225
+ scope: str,
226
+ key: str,
227
+ lakebase_key: str | None,
228
+ app_yaml_key: str | None,
229
+ ) -> tuple[str, bool]:
230
+ """Decide which key secret mode should hold. Returns (value, wrote_secret).
231
+
232
+ First hit wins:
233
+ 1. A valid key already in the secret — authoritative, and hard-fails if it
234
+ disagrees with an existing Lakebase row (mirrors the guard in
235
+ ``_migrate_encryption_key_to_lakebase``).
236
+ 2. The existing Lakebase row — the relocate case.
237
+ 3. A legacy ``GOOGLE_OAUTH_ENCRYPTION_KEY`` still in the deployed app.yaml.
238
+ 4. Nothing anywhere — generate fresh.
239
+ """
240
+ existing = read_secret_key(ws, scope, key)
241
+ if existing:
242
+ if lakebase_key and lakebase_key != existing:
243
+ raise SecretKeyError(
244
+ f"Secret {scope}/{key} holds a different key from "
245
+ f"encryption_keys. Refusing to continue: attaching the secret and "
246
+ f"deleting the row would orphan every credential encrypted under "
247
+ f"the other key. Resolve which key is correct, then re-run."
248
+ )
249
+ logger.info("Secret %s/%s already holds the key — reusing", scope, key)
250
+ return existing, False
251
+
252
+ value = lakebase_key or app_yaml_key or Fernet.generate_key().decode()
253
+ write_and_verify_secret_key(ws, scope, key, value)
254
+ return value, True
255
+
256
+
257
+ def app_reports_secret_source(
258
+ ws: Any, app_url: str, attempts: int = 10, delay: float = 6.0
259
+ ) -> bool:
260
+ """Return True only if the live app reports it is reading from the secret.
261
+
262
+ This is the gate on deleting the Lakebase key row. It **fails closed**: a
263
+ missing field, an unparseable body, a non-200, or a network error all return
264
+ False, because every one of those is indistinguishable from "the deployed code
265
+ does not know about secret mode". An app pinned to a pre-feature version has
266
+ no ``encryption_key_source`` field at all, which is exactly the case that must
267
+ not delete the row.
268
+
269
+ Databricks Apps sit behind the workspace proxy, so the request carries
270
+ workspace credentials from ``ws.config.authenticate()``.
271
+ """
272
+ url = f"{app_url.rstrip('/')}/api/health"
273
+ for attempt in range(1, attempts + 1):
274
+ try:
275
+ resp = requests.get(url, headers=ws.config.authenticate(), timeout=15)
276
+ if resp.status_code == 200:
277
+ source = resp.json().get("encryption_key_source")
278
+ if source == "secret":
279
+ return True
280
+ logger.info(
281
+ "Health gate attempt %s/%s: key source is %r, not 'secret'",
282
+ attempt, attempts, source,
283
+ )
284
+ else:
285
+ logger.info(
286
+ "Health gate attempt %s/%s: HTTP %s", attempt, attempts,
287
+ resp.status_code,
288
+ )
289
+ except Exception as exc: # noqa: BLE001 — any failure means "not confirmed"
290
+ logger.info("Health gate attempt %s/%s failed: %s", attempt, attempts, exc)
291
+ if attempt < attempts and delay:
292
+ time.sleep(delay)
293
+ return False
294
+
295
+
296
+ def attach_secret_resource(ws: Any, app_name: str, scope: str, key: str) -> None:
297
+ """Add or replace the secret resource on an existing app.
298
+
299
+ A GET-fetched ``App`` cannot be passed back: ``apps.update`` rejects it with
300
+ "Compute size updates are not supported in this update API" (verified). It is
301
+ also a full replace on the fields it does accept — omitting ``description``
302
+ blanks it and omitting ``user_api_scopes`` nulls it. So build a fresh App,
303
+ carry every mutable field explicitly, and omit ``compute_size``.
304
+ """
305
+ cur = ws.apps.get(name=app_name)
306
+ # Filter by name only: an app may legitimately carry other secret
307
+ # resources, and dropping those would silently break it.
308
+ kept = [r for r in (cur.resources or []) if r.name != RESOURCE_KEY]
309
+ kept.append(build_secret_resource(scope, key))
310
+ ws.apps.update(
311
+ name=app_name,
312
+ app=App(
313
+ name=app_name,
314
+ description=cur.description,
315
+ default_source_code_path=cur.default_source_code_path,
316
+ user_api_scopes=cur.user_api_scopes,
317
+ resources=kept,
318
+ # compute_size deliberately omitted — passing it is rejected.
319
+ ),
320
+ )
321
+ logger.info("Attached %s resource to app %s", RESOURCE_KEY, app_name)
322
+
323
+
324
+ def delete_lakebase_key_row(cur: Any, schema_name: str) -> None:
325
+ """Remove the relocated key row. Only ever called behind the health gate."""
326
+ cur.execute(f'DELETE FROM "{schema_name}".encryption_keys WHERE id = 1')
327
+ logger.info("Deleted the relocated key row from %s.encryption_keys", schema_name)
328
+
329
+
330
+ def app_is_secret_mode(ws: Any, app_name: str) -> bool:
331
+ """True when the app already carries the encryption-key secret resource."""
332
+ try:
333
+ app = ws.apps.get(name=app_name)
334
+ except Exception: # noqa: BLE001 — absence is not secret mode
335
+ return False
336
+ return any(r.name == RESOURCE_KEY for r in (app.resources or []))
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr
3
- Version: 0.4.3.dev12
3
+ Version: 0.4.3.dev13
4
4
  Summary: Tellr deployment tooling for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: databricks-sdk>=0.85.0
8
8
  Requires-Dist: psycopg2-binary>=2.9.0
9
9
  Requires-Dist: pyyaml>=6.0.0
10
+ Requires-Dist: requests>=2.31.0
10
11
 
11
12
  # databricks-tellr
12
13
 
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  databricks_tellr/__init__.py
4
4
  databricks_tellr/deploy.py
5
5
  databricks_tellr/identifiers.py
6
+ databricks_tellr/secret_key.py
6
7
  databricks_tellr.egg-info/PKG-INFO
7
8
  databricks_tellr.egg-info/SOURCES.txt
8
9
  databricks_tellr.egg-info/dependency_links.txt
@@ -1,3 +1,4 @@
1
1
  databricks-sdk>=0.85.0
2
2
  psycopg2-binary>=2.9.0
3
3
  pyyaml>=6.0.0
4
+ requests>=2.31.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databricks-tellr"
7
- version = "0.4.3.dev12"
7
+ version = "0.4.3.dev13"
8
8
  description = "Tellr deployment tooling for Databricks Apps"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "databricks-sdk>=0.85.0",
13
13
  "psycopg2-binary>=2.9.0",
14
14
  "pyyaml>=6.0.0",
15
+ "requests>=2.31.0",
15
16
  ]
16
17
 
17
18
  [tool.setuptools.packages.find]