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.
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/PKG-INFO +2 -1
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/_templates/app.yaml.template +1 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/deploy.py +160 -6
- databricks_tellr-0.4.3.dev13/databricks_tellr/secret_key.py +336 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/PKG-INFO +2 -1
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/SOURCES.txt +1 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/requires.txt +1 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/pyproject.toml +2 -1
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/README.md +0 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/__init__.py +0 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/_templates/requirements.txt.template +0 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/identifiers.py +0 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/dependency_links.txt +0 -0
- {databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 []))
|
{databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr.egg-info/PKG-INFO
RENAMED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: databricks-tellr
|
|
3
|
-
Version: 0.4.3.
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "databricks-tellr"
|
|
7
|
-
version = "0.4.3.
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{databricks_tellr-0.4.3.dev12 → databricks_tellr-0.4.3.dev13}/databricks_tellr/identifiers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|