From ad1df65334c192b3700add1c862e87439f067b3c Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Thu, 20 Nov 2025 16:33:34 +0100 Subject: [PATCH 01/17] prompt for recon secrets --- src/databricks/labs/lakebridge/cli.py | 13 -- .../lakebridge/helpers/recon_config_utils.py | 128 +++++++++++------- src/databricks/labs/lakebridge/install.py | 16 ++- tests/integration/config/test_config.py | 3 + tests/unit/helpers/test_recon_config_utils.py | 110 +++------------ tests/unit/test_cli_other.py | 21 --- tests/unit/test_install.py | 60 ++++++-- 7 files changed, 161 insertions(+), 190 deletions(-) diff --git a/src/databricks/labs/lakebridge/cli.py b/src/databricks/labs/lakebridge/cli.py index a2d198f47f..cfcea8b3ea 100644 --- a/src/databricks/labs/lakebridge/cli.py +++ b/src/databricks/labs/lakebridge/cli.py @@ -27,7 +27,6 @@ from databricks.labs.lakebridge.config import TranspileConfig, LSPConfigOptionV1 from databricks.labs.lakebridge.contexts.application import ApplicationContext from databricks.labs.lakebridge.connections.credential_manager import cred_file -from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts from databricks.labs.lakebridge.helpers.telemetry_utils import make_alphanum_or_semver from databricks.labs.lakebridge.install import installer from databricks.labs.lakebridge.reconcile.runner import ReconcileRunner @@ -701,18 +700,6 @@ def generate_lineage( lineage_generator(engine, source_dialect, input_source, output_folder) -@lakebridge.command -def configure_secrets(*, w: WorkspaceClient) -> None: - """Setup reconciliation connection profile details as Secrets on Databricks Workspace""" - recon_conf = ReconConfigPrompts(w) - - # Prompt for source - source = recon_conf.prompt_source() - - logger.info(f"Setting up Scope, Secrets for `{source}` reconciliation") - recon_conf.prompt_and_save_connection_details() - - @lakebridge.command def configure_database_profiler(w: WorkspaceClient) -> None: """[Experimental] Installs and runs the Lakebridge Assessment package for database profiling""" diff --git a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py index e798edbf77..241ea8771e 100644 --- a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py +++ b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py @@ -10,7 +10,6 @@ class ReconConfigPrompts: def __init__(self, ws: WorkspaceClient, prompts: Prompts = Prompts()): - self._source = None self._prompts = prompts self._ws = ws @@ -87,14 +86,7 @@ def store_connection_secrets(self, scope_name: str, conn_details: tuple[str, dic self._store_secret(scope_name, secret_key, value) logger.info(f"{info_op} Secret: *{secret_key}* in Scope: `{scope_name}`") - def prompt_source(self): - source = self._prompts.choice( - "Select the source dialect", [source_type.value for source_type in ReconSourceType] - ) - self._source = source - return source - - def _prompt_snowflake_connection_details(self) -> tuple[str, dict[str, str]]: + def _prompt_snowflake_connection_details(self) -> dict[str, str]: """ Prompt for Snowflake connection details :return: tuple[str, dict[str, str]] @@ -103,30 +95,38 @@ def _prompt_snowflake_connection_details(self) -> tuple[str, dict[str, str]]: f"Please answer a couple of questions to configure `{ReconSourceType.SNOWFLAKE.value}` Connection profile" ) - sf_url = self._prompts.question("Enter Snowflake URL") - account = self._prompts.question("Enter Account Name") - sf_user = self._prompts.question("Enter User") - sf_password = self._prompts.question("Enter Password") - sf_db = self._prompts.question("Enter Database") - sf_schema = self._prompts.question("Enter Schema") - sf_warehouse = self._prompts.question("Enter Snowflake Warehouse") - sf_role = self._prompts.question("Enter Role", default=" ") + sf_url = self._prompts.question("Enter Snowflake URL Secret") + sf_user = self._prompts.question("Enter User Secret") + password_dict = {} + sf_password = self._prompts.question("Enter Password Secret if using password authentication else leave blank") + if not sf_password: + logger.info("Proceeding with PEM Private Key authentication...") + sf_pem_key = self._prompts.question("Enter PEM Private Key Secret") + password_dict["pem_private_key"] = sf_pem_key + sf_pem_key_password = self._prompts.question( + "Enter PEM Private Key Password Secret if used else leave blank" + ) + if sf_pem_key_password: + password_dict["pem_private_key_password"] = sf_pem_key_password + else: + password_dict["sfPassword"] = sf_password + sf_db = self._prompts.question("Enter Database Secret") + sf_schema = self._prompts.question("Enter Schema Secret") + sf_warehouse = self._prompts.question("Enter Snowflake Warehouse Secret") + sf_role = self._prompts.question("Enter Role Secret") sf_conn_details = { "sfUrl": sf_url, - "account": account, "sfUser": sf_user, - "sfPassword": sf_password, "sfDatabase": sf_db, "sfSchema": sf_schema, "sfWarehouse": sf_warehouse, "sfRole": sf_role, - } + } | password_dict - sf_conn_dict = (ReconSourceType.SNOWFLAKE.value, sf_conn_details) - return sf_conn_dict + return sf_conn_details - def _prompt_oracle_connection_details(self) -> tuple[str, dict[str, str]]: + def _prompt_oracle_connection_details(self) -> dict[str, str]: """ Prompt for Oracle connection details :return: tuple[str, dict[str, str]] @@ -134,43 +134,67 @@ def _prompt_oracle_connection_details(self) -> tuple[str, dict[str, str]]: logger.info( f"Please answer a couple of questions to configure `{ReconSourceType.ORACLE.value}` Connection profile" ) - user = self._prompts.question("Enter User") - password = self._prompts.question("Enter Password") - host = self._prompts.question("Enter host") - port = self._prompts.question("Enter port") - database = self._prompts.question("Enter database/SID") + user = self._prompts.question("Enter User Secret") + password = self._prompts.question("Enter Password Secret") + host = self._prompts.question("Enter host Secret") + port = self._prompts.question("Enter port Secret") + database = self._prompts.question("Enter database/SID Secret") oracle_conn_details = {"user": user, "password": password, "host": host, "port": port, "database": database} - oracle_conn_dict = (ReconSourceType.ORACLE.value, oracle_conn_details) - return oracle_conn_dict + return oracle_conn_details - def _connection_details(self): + def _prompt_mssql_connection_details(self) -> dict[str, str]: """ - Prompt for connection details based on the source - :return: None + Prompt for Oracle connection details + :return: tuple[str, dict[str, str]] """ - logger.debug(f"Prompting for `{self._source}` connection details") - match self._source: + logger.info( + f"Please answer a couple of questions to configure `{ReconSourceType.MSSQL.value}`/`{ReconSourceType.SYNAPSE.value}` Connection profile" + ) + user = self._prompts.question("Enter User Secret") + password = self._prompts.question("Enter Password Secret") + host = self._prompts.question("Enter host Secret") + port = self._prompts.question("Enter port Secret") + database = self._prompts.question("Enter database Secret") + encrypt = self._prompts.question("Enter Encrypt Secret") + trust_server_certificate = self._prompts.question("Enter Trust Server Certificate Secret") + + tsql_conn_details = { + "user": user, + "password": password, + "host": host, + "port": port, + "database": database, + "encrypt": encrypt, + "trustServerCertificate": trust_server_certificate, + } + + return tsql_conn_details + + def _connection_details(self, source: str): + logger.debug(f"Prompting for `{source}` connection details") + match source: case ReconSourceType.SNOWFLAKE.value: return self._prompt_snowflake_connection_details() case ReconSourceType.ORACLE.value: return self._prompt_oracle_connection_details() + case ReconSourceType.MSSQL.value | ReconSourceType.SYNAPSE.value: + return self._prompt_mssql_connection_details() - def prompt_and_save_connection_details(self): - """ - Prompt for connection details and save them as Secrets in Databricks Workspace - """ - # prompt for connection_details only if source is other than Databricks - if self._source == ReconSourceType.DATABRICKS.value: - logger.info("*Databricks* as a source is supported only for **Hive MetaStore (HMS) setup**") - return - - # Prompt for secret scope - scope_name = self._prompts.question("Enter Secret Scope name") - self._ensure_scope_exists(scope_name) - - # Prompt for connection details - connection_details = self._connection_details() - logger.debug(f"Storing `{self._source}` connection details as Secrets in Databricks Workspace...") - self.store_connection_secrets(scope_name, connection_details) + def prompt_recon_creds(self, source: str): + logger.info( + "\n(local | env | databricks) \nlocal means values are read as plain text \nenv means values are read " + "from environment variables fall back to plain text if not variable is not found\ndatabricks means values are read from Databricks Secrets\n", + ) + secret_vault_type = str( + self._prompts.choice("Enter secret vault type (local | env | databricks)", ["local", "env", "databricks"]) + ).lower() + + if secret_vault_type == "databricks": + logger.info( + "Since you have chosen `databricks` as secret vault type, you need to provide secret names in the following steps in the format /" + ) + + connection_details = self._connection_details(source) + return secret_vault_type, connection_details diff --git a/src/databricks/labs/lakebridge/install.py b/src/databricks/labs/lakebridge/install.py index 9957d6426d..e1b56eaf37 100644 --- a/src/databricks/labs/lakebridge/install.py +++ b/src/databricks/labs/lakebridge/install.py @@ -20,10 +20,12 @@ LakebridgeConfiguration, ReconcileMetadataConfig, TranspileConfig, + ReconcileCredentialConfig, ) from databricks.labs.lakebridge.contexts.application import ApplicationContext from databricks.labs.lakebridge.deployment.configurator import ResourceConfigurator from databricks.labs.lakebridge.deployment.installation import WorkspaceInstallation +from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts from databricks.labs.lakebridge.reconcile.constants import ReconReportType, ReconSourceType from databricks.labs.lakebridge.transpiler.installers import ( BladebridgeInstaller, @@ -47,6 +49,7 @@ def __init__( # pylint: disable=too-many-arguments install_state: InstallState, product_info: ProductInfo, resource_configurator: ResourceConfigurator, + recon_creds_prompts: ReconConfigPrompts, workspace_installation: WorkspaceInstallation, environ: dict[str, str] | None = None, *, @@ -64,6 +67,7 @@ def __init__( # pylint: disable=too-many-arguments self._install_state = install_state self._product_info = product_info self._resource_configurator = resource_configurator + self._recon_creds_prompts = recon_creds_prompts self._ws_installation = workspace_installation # TODO: Refactor the 'prompts' property in preference to using this flag, which should be redundant. self._is_interactive = is_interactive @@ -325,10 +329,10 @@ def _prompt_for_new_reconcile_installation(self) -> ReconcileConfig: report_type = self._prompts.choice( "Select the report type:", [report_type.value for report_type in ReconReportType] ) - scope_name = self._prompts.question( # TODO deprecate - f"Enter Secret scope name to store `{data_source.capitalize()}` connection details / secrets", - default=f"remorph_{data_source}", - ) + creds_or_secret_scope: str | ReconcileCredentialConfig = "NOT_USED" + if data_source != ReconSourceType.DATABRICKS.value: + vault, credentials = self._recon_creds_prompts.prompt_recon_creds(data_source) + creds_or_secret_scope = ReconcileCredentialConfig(vault, credentials) db_config = self._prompt_for_reconcile_database_config(data_source) metadata_config = self._prompt_for_reconcile_metadata_config() @@ -336,9 +340,10 @@ def _prompt_for_new_reconcile_installation(self) -> ReconcileConfig: return ReconcileConfig( data_source=data_source, report_type=report_type, - secret_scope=scope_name, + secret_scope="NOT_USED", database_config=db_config, metadata_config=metadata_config, + creds_or_secret_scope=creds_or_secret_scope, ) def _prompt_for_reconcile_database_config(self, source) -> DatabaseConfig: @@ -410,6 +415,7 @@ def installer( app_context.install_state, app_context.product_info, app_context.resource_configurator, + ReconConfigPrompts(ws, app_context.prompts), app_context.workspace_installation, transpiler_repository=transpiler_repository, is_interactive=is_interactive, diff --git a/tests/integration/config/test_config.py b/tests/integration/config/test_config.py index afc61c1e5c..57965b2d5e 100644 --- a/tests/integration/config/test_config.py +++ b/tests/integration/config/test_config.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + from databricks.sdk import WorkspaceClient from databricks.labs.blueprint.tui import MockPrompts @@ -26,6 +28,7 @@ def test_stores_and_fetches_config(ws: WorkspaceClient) -> None: context.install_state, context.product_info, context.resource_configurator, + MagicMock(), context.workspace_installation, ) config = TranspileConfig( diff --git a/tests/unit/helpers/test_recon_config_utils.py b/tests/unit/helpers/test_recon_config_utils.py index 84558295b3..6f463319e2 100644 --- a/tests/unit/helpers/test_recon_config_utils.py +++ b/tests/unit/helpers/test_recon_config_utils.py @@ -1,45 +1,34 @@ -from unittest.mock import patch - import pytest from databricks.labs.blueprint.tui import MockPrompts from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts from databricks.sdk.errors.platform import ResourceDoesNotExist -from databricks.sdk.service.workspace import SecretScope -SOURCE_DICT = {"databricks": "0", "mssql": "1", "oracle": "2", "snowflake": "3", "synapse": "4"} -SCOPE_NAME = "dummy_scope" +from databricks.labs.lakebridge.reconcile.constants import ReconSourceType -def test_configure_secrets_snowflake_overwrite(mock_workspace_client): +def test_configure_secrets_snowflake(mock_workspace_client): prompts = MockPrompts( { - r"Select the source": SOURCE_DICT["snowflake"], - r"Enter Secret Scope name": SCOPE_NAME, + r"Enter secret vault type": "0", r"Enter Snowflake URL": "dummy", - r"Enter Account Name": "dummy", r"Enter User": "dummy", - r"Enter Password": "dummy", + r"Enter Password*": "dummy", r"Enter Database": "dummy", r"Enter Schema": "dummy", r"Enter Snowflake Warehouse": "dummy", r"Enter Role": "dummy", - r"Do you want to overwrite.*": "yes", } ) - mock_workspace_client.secrets.list_scopes.side_effect = [[SecretScope(name=SCOPE_NAME)]] recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_source() - - recon_conf.prompt_and_save_connection_details() + recon_conf.prompt_recon_creds(ReconSourceType.SNOWFLAKE) -def test_configure_secrets_oracle_insert(mock_workspace_client): +def test_configure_secrets_oracle(mock_workspace_client): # mock prompts for Oracle prompts = MockPrompts( { - r"Select the source": SOURCE_DICT["oracle"], - r"Enter Secret Scope name": SCOPE_NAME, + r"Enter secret vault type": "1", r"Do you want to create a new one?": "yes", r"Enter User": "dummy", r"Enter Password": "dummy", @@ -49,33 +38,27 @@ def test_configure_secrets_oracle_insert(mock_workspace_client): } ) - mock_workspace_client.secrets.list_scopes.side_effect = [[SecretScope(name="scope_name")]] - - with patch( - "databricks.labs.lakebridge.helpers.recon_config_utils.ReconConfigPrompts._secret_key_exists", - return_value=False, - ): - recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_source() - - recon_conf.prompt_and_save_connection_details() + recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) + recon_conf.prompt_recon_creds(ReconSourceType.ORACLE) -def test_configure_secrets_invalid_source(mock_workspace_client): +def test_configure_secrets_tsql(mock_workspace_client): prompts = MockPrompts( { - r"Select the source": "100", # Invalid source - r"Enter Secret Scope name": SCOPE_NAME, + r"Enter secret vault type": "2", + r"Enter User": "dummy", + r"Enter Password": "dummy", + r"Enter host": "dummy", + r"Enter port": "dummy", + r"Enter database": "dummy", + r"Enter Encrypt": "dummy", + r"Enter Trust Server Certificate": "dummy", } ) - with patch( - "databricks.labs.lakebridge.helpers.recon_config_utils.ReconConfigPrompts._scope_exists", - return_value=True, - ): - recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - with pytest.raises(ValueError, match="cannot get answer within 10 attempt"): - recon_conf.prompt_source() + recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) + recon_conf.prompt_recon_creds(ReconSourceType.MSSQL) + recon_conf.prompt_recon_creds(ReconSourceType.SYNAPSE) def test_store_connection_secrets_exception(mock_workspace_client): @@ -92,54 +75,3 @@ def test_store_connection_secrets_exception(mock_workspace_client): with pytest.raises(Exception, match="Timed out"): recon_conf.store_connection_secrets("scope_name", ("source", {"key": "value"})) - - -def test_configure_secrets_no_scope(mock_workspace_client): - prompts = MockPrompts( - { - r"Select the source": SOURCE_DICT["snowflake"], - r"Enter Secret Scope name": SCOPE_NAME, - r"Do you want to create a new one?": "no", - } - ) - - mock_workspace_client.secrets.list_scopes.side_effect = [[SecretScope(name="scope_name")]] - - recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_source() - - with pytest.raises(SystemExit, match="Scope is needed to store Secrets in Databricks Workspace"): - recon_conf.prompt_and_save_connection_details() - - -def test_configure_secrets_create_scope_exception(mock_workspace_client): - prompts = MockPrompts( - { - r"Select the source": SOURCE_DICT["snowflake"], - r"Enter Secret Scope name": SCOPE_NAME, - r"Do you want to create a new one?": "yes", - } - ) - - mock_workspace_client.secrets.list_scopes.side_effect = [[SecretScope(name="scope_name")]] - mock_workspace_client.secrets.create_scope.side_effect = Exception("Network Error") - - recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_source() - - with pytest.raises(Exception, match="Network Error"): - recon_conf.prompt_and_save_connection_details() - - -def test_store_connection_secrets_overwrite(mock_workspace_client): - prompts = MockPrompts( - { - r"Do you want to overwrite `key`?": "no", - } - ) - - with patch( - "databricks.labs.lakebridge.helpers.recon_config_utils.ReconConfigPrompts._secret_key_exists", return_value=True - ): - recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.store_connection_secrets("scope_name", ("source", {"key": "value"})) diff --git a/tests/unit/test_cli_other.py b/tests/unit/test_cli_other.py index 5d184bffc0..c2a6568709 100644 --- a/tests/unit/test_cli_other.py +++ b/tests/unit/test_cli_other.py @@ -6,21 +6,6 @@ from databricks.labs.blueprint.tui import MockPrompts from databricks.labs.lakebridge import cli from databricks.labs.lakebridge.config import LSPConfigOptionV1, LSPPromptMethod -from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts - - -def test_configure_secrets_databricks(mock_workspace_client): - source_dict = {"databricks": "0", "netezza": "1", "oracle": "2", "snowflake": "3"} - prompts = MockPrompts( - { - r"Select the source": source_dict["databricks"], - } - ) - - recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_source() - - recon_conf.prompt_and_save_connection_details() @pytest.mark.parametrize( @@ -60,12 +45,6 @@ def test_interactive_argument_auto(is_tty: bool) -> None: assert interactive_mode is is_tty -def test_cli_configure_secrets_config(mock_workspace_client): - with patch("databricks.labs.lakebridge.cli.ReconConfigPrompts") as mock_recon_config: - cli.configure_secrets(w=mock_workspace_client) - mock_recon_config.assert_called_once_with(mock_workspace_client) - - def test_cli_reconcile(mock_workspace_client): with patch("databricks.labs.lakebridge.reconcile.runner.ReconcileRunner.run", return_value=True): cli.reconcile(w=mock_workspace_client) diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index e599ca5f68..bbb88db073 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -1,7 +1,7 @@ import logging from collections.abc import Callable, Generator, Sequence from pathlib import Path -from unittest.mock import create_autospec, patch +from unittest.mock import create_autospec, patch, MagicMock import pytest from databricks.labs.blueprint.installation import JsonObject, MockInstallation @@ -17,10 +17,12 @@ ReconcileConfig, ReconcileMetadataConfig, TranspileConfig, + ReconcileCredentialConfig, ) from databricks.labs.lakebridge.contexts.application import ApplicationContext from databricks.labs.lakebridge.deployment.configurator import ResourceConfigurator from databricks.labs.lakebridge.deployment.installation import WorkspaceInstallation +from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts from databricks.labs.lakebridge.install import WorkspaceInstaller from databricks.labs.lakebridge.reconcile.constants import ReconSourceType, ReconReportType from databricks.labs.lakebridge.transpiler.installers import ( @@ -91,6 +93,7 @@ def test_workspace_installer_run_raise_error_in_dbr(ws: WorkspaceClient) -> None ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, environ=environ, ) @@ -116,6 +119,7 @@ def test_workspace_installer_run_install_not_called_in_test( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -143,6 +147,7 @@ def test_workspace_installer_run_install_called_with_provided_config( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -165,6 +170,7 @@ def test_configure_error_if_invalid_module_selected(ws: WorkspaceClient) -> None ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -204,6 +210,7 @@ def test_workspace_installer_run_install_called_with_generated_config( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) workspace_installer.run("transpile") @@ -254,6 +261,7 @@ def test_configure_transpile_no_existing_installation( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -323,6 +331,7 @@ def test_configure_transpile_installation_no_override(ws: WorkspaceClient) -> No ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) remorph_config = workspace_installer.configure(module="transpile") @@ -384,6 +393,7 @@ def test_configure_transpile_installation_config_error_continue_install( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -447,6 +457,7 @@ def test_configure_transpile_installation_with_no_validation(ws, ws_installer): ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -518,6 +529,7 @@ def test_configure_transpile_installation_with_validation_and_warehouse_id_from_ ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -594,6 +606,7 @@ def test_configure_reconcile_installation_no_override(ws: WorkspaceClient) -> No ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) with pytest.raises(SystemExit): @@ -646,6 +659,9 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work workspace_installation=create_autospec(WorkspaceInstallation), ) + creds_mock = MagicMock(ReconConfigPrompts) + creds_sample = ReconcileCredentialConfig("local", {"test_secret": "dummy"}) + creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.source_creds) workspace_installer = WorkspaceInstaller( ctx.workspace_client, ctx.prompts, @@ -653,6 +669,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work ctx.install_state, ctx.product_info, ctx.resource_configurator, + creds_mock, ctx.workspace_installation, ) config = workspace_installer.configure(module="reconcile") @@ -661,7 +678,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work reconcile=ReconcileConfig( data_source="oracle", report_type="all", - secret_scope="remorph_oracle", + secret_scope="NOT_USED", database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -672,6 +689,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work schema="reconcile", volume="reconcile_volume", ), + creds_or_secret_scope=creds_sample, ), transpile=None, ) @@ -681,7 +699,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work { "data_source": "oracle", "report_type": "all", - "secret_scope": "remorph_oracle", + "secret_scope": "NOT_USED", "database_config": { "source_schema": "tpch_sf1000", "target_catalog": "tpch", @@ -692,6 +710,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work "schema": "reconcile", "volume": "reconcile_volume", }, + "creds_or_secret_scope": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "version": 1, }, ) @@ -703,7 +722,6 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No { r"Select the Data Source": str(RECONCILE_DATA_SOURCES.index("snowflake")), r"Select the report type": str(RECONCILE_REPORT_TYPES.index("all")), - r"Enter Secret scope name to store .* connection details / secrets": "remorph_snowflake", r"Enter source catalog name for .*": "snowflake_sample_data", r"Enter source schema name for .*": "tpch_sf1000", r"Enter target catalog name for Databricks": "tpch", @@ -725,6 +743,9 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No workspace_installation=create_autospec(WorkspaceInstallation), ) + creds_mock = MagicMock(ReconConfigPrompts) + creds_sample = ReconcileCredentialConfig("local", {"test_secret": "dummy"}) + creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.source_creds) workspace_installer = WorkspaceInstaller( ctx.workspace_client, ctx.prompts, @@ -732,6 +753,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No ctx.install_state, ctx.product_info, ctx.resource_configurator, + creds_mock, ctx.workspace_installation, ) config = workspace_installer.configure(module="reconcile") @@ -740,7 +762,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No reconcile=ReconcileConfig( data_source="snowflake", report_type="all", - secret_scope="remorph_snowflake", + secret_scope="NOT_USED", database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -752,6 +774,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No schema="reconcile", volume="reconcile_volume", ), + creds_or_secret_scope=creds_sample, ), transpile=None, ) @@ -761,7 +784,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No { "data_source": "snowflake", "report_type": "all", - "secret_scope": "remorph_snowflake", + "secret_scope": "NOT_USED", "database_config": { "source_catalog": "snowflake_sample_data", "source_schema": "tpch_sf1000", @@ -773,6 +796,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No "schema": "reconcile", "volume": "reconcile_volume", }, + "creds_or_secret_scope": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "version": 1, }, ) @@ -794,7 +818,6 @@ def test_configure_all_override_installation( r"Open .* in the browser?": "no", r"Select the Data Source": str(RECONCILE_DATA_SOURCES.index("snowflake")), r"Select the report type": str(RECONCILE_REPORT_TYPES.index("all")), - r"Enter Secret scope name to store .* connection details / secrets": "remorph_snowflake", r"Enter source catalog name for .*": "snowflake_sample_data", r"Enter source schema name for .*": "tpch_sf1000", r"Enter target catalog name for Databricks": "tpch", @@ -819,7 +842,7 @@ def test_configure_all_override_installation( "reconcile.yml": { "data_source": "snowflake", "report_type": "all", - "secret_scope": "remorph_snowflake", + "secret_scope": "NOT_USED", "database_config": { "source_catalog": "snowflake_sample_data", "source_schema": "tpch_sf1000", @@ -849,6 +872,9 @@ def test_configure_all_override_installation( workspace_installation=create_autospec(WorkspaceInstallation), ) + creds_mock = MagicMock(ReconConfigPrompts) + creds_sample = ReconcileCredentialConfig("local", {"test_secret": "dummy"}) + creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.source_creds) workspace_installer = ws_installer( ctx.workspace_client, ctx.prompts, @@ -856,6 +882,7 @@ def test_configure_all_override_installation( ctx.install_state, ctx.product_info, ctx.resource_configurator, + creds_mock, ctx.workspace_installation, ) @@ -876,7 +903,7 @@ def test_configure_all_override_installation( expected_reconcile_config = ReconcileConfig( data_source="snowflake", report_type="all", - secret_scope="remorph_snowflake", + secret_scope="NOT_USED", database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -888,6 +915,7 @@ def test_configure_all_override_installation( schema="reconcile", volume="reconcile_volume", ), + creds_or_secret_scope=creds_sample, ) expected_config = LakebridgeConfiguration(transpile=expected_transpile_config, reconcile=expected_reconcile_config) assert config == expected_config @@ -911,7 +939,7 @@ def test_configure_all_override_installation( { "data_source": "snowflake", "report_type": "all", - "secret_scope": "remorph_snowflake", + "secret_scope": "NOT_USED", "database_config": { "source_catalog": "snowflake_sample_data", "source_schema": "tpch_sf1000", @@ -923,6 +951,7 @@ def test_configure_all_override_installation( "schema": "reconcile", "volume": "reconcile_volume", }, + "creds_or_secret_scope": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "version": 1, }, ) @@ -989,6 +1018,7 @@ def test_runs_upgrades_on_more_recent_version( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, ) @@ -1059,6 +1089,7 @@ def transpilers_path(self) -> Path: ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, transpiler_repository=_TranspilerRepository(), ) @@ -1148,6 +1179,7 @@ def test_runs_and_stores_force_config_option( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, transpiler_repository=transpiler_repository, ) @@ -1230,6 +1262,7 @@ def test_runs_and_stores_question_config_option( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, transpiler_repository=transpiler_repository, ) @@ -1318,6 +1351,7 @@ def test_runs_and_stores_choice_config_option( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, transpiler_repository=transpiler_repository, ) @@ -1372,6 +1406,7 @@ def test_installer_detects_installed_transpilers( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, transpiler_repository=mock_repository, ) @@ -1433,6 +1468,7 @@ def mock_factory(self, repository: TranspilerRepository) -> TranspilerInstaller: ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, transpiler_repository=mock_repository, transpiler_installers=(baz_installer.mock_factory, bar_installer.mock_factory), @@ -1510,6 +1546,7 @@ def install(self, artifact: Path | None = None) -> bool: ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, transpiler_repository=mock_repository, transpiler_installers=(MockTranspilerInstaller,), @@ -1568,6 +1605,7 @@ def test_no_reconfigure_if_noninteractive( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, is_interactive=False, ) @@ -1601,6 +1639,7 @@ def test_no_configure_if_noninteractive( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, is_interactive=False, ) @@ -1641,6 +1680,7 @@ def test_transpiler_installers_llm_flag( ctx.install_state, ctx.product_info, ctx.resource_configurator, + MagicMock(), ctx.workspace_installation, is_interactive=False, **kw_args, From abb7459a1ef47cea28ac93c5c677015c5e043b9b Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Thu, 20 Nov 2025 16:52:34 +0100 Subject: [PATCH 02/17] add one more test --- .../lakebridge/helpers/recon_config_utils.py | 8 +++--- tests/unit/helpers/test_recon_config_utils.py | 27 ++++++++++++++++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py index 241ea8771e..131fbe6060 100644 --- a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py +++ b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py @@ -98,14 +98,12 @@ def _prompt_snowflake_connection_details(self) -> dict[str, str]: sf_url = self._prompts.question("Enter Snowflake URL Secret") sf_user = self._prompts.question("Enter User Secret") password_dict = {} - sf_password = self._prompts.question("Enter Password Secret if using password authentication else leave blank") + sf_password = self._prompts.question("Enter Password Secret or leave empty to use key-based auth") if not sf_password: logger.info("Proceeding with PEM Private Key authentication...") sf_pem_key = self._prompts.question("Enter PEM Private Key Secret") password_dict["pem_private_key"] = sf_pem_key - sf_pem_key_password = self._prompts.question( - "Enter PEM Private Key Password Secret if used else leave blank" - ) + sf_pem_key_password = self._prompts.question("Enter PEM Private Key Password Secret or leave empty") if sf_pem_key_password: password_dict["pem_private_key_password"] = sf_pem_key_password else: @@ -182,7 +180,7 @@ def _connection_details(self, source: str): case ReconSourceType.MSSQL.value | ReconSourceType.SYNAPSE.value: return self._prompt_mssql_connection_details() - def prompt_recon_creds(self, source: str): + def prompt_recon_creds(self, source: str) -> tuple[str, dict[str, str]]: logger.info( "\n(local | env | databricks) \nlocal means values are read as plain text \nenv means values are read " "from environment variables fall back to plain text if not variable is not found\ndatabricks means values are read from Databricks Secrets\n", diff --git a/tests/unit/helpers/test_recon_config_utils.py b/tests/unit/helpers/test_recon_config_utils.py index 6f463319e2..8296f6e3f8 100644 --- a/tests/unit/helpers/test_recon_config_utils.py +++ b/tests/unit/helpers/test_recon_config_utils.py @@ -21,7 +21,26 @@ def test_configure_secrets_snowflake(mock_workspace_client): } ) recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_recon_creds(ReconSourceType.SNOWFLAKE) + recon_conf.prompt_recon_creds(ReconSourceType.SNOWFLAKE.value) + + +def test_configure_secrets_snowflake_pem(mock_workspace_client): + prompts = MockPrompts( + { + r"Enter secret vault type": "0", + r"Enter Snowflake URL": "dummy", + r"Enter User": "dummy", + r"Enter Password*": "", + r"Enter PEM*": "dummy", + r"Enter PEM*Password*": "dummy", + r"Enter Database": "dummy", + r"Enter Schema": "dummy", + r"Enter Snowflake Warehouse": "dummy", + r"Enter Role": "dummy", + } + ) + recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) + recon_conf.prompt_recon_creds(ReconSourceType.SNOWFLAKE.value) def test_configure_secrets_oracle(mock_workspace_client): @@ -39,7 +58,7 @@ def test_configure_secrets_oracle(mock_workspace_client): ) recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_recon_creds(ReconSourceType.ORACLE) + recon_conf.prompt_recon_creds(ReconSourceType.ORACLE.value) def test_configure_secrets_tsql(mock_workspace_client): @@ -57,8 +76,8 @@ def test_configure_secrets_tsql(mock_workspace_client): ) recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - recon_conf.prompt_recon_creds(ReconSourceType.MSSQL) - recon_conf.prompt_recon_creds(ReconSourceType.SYNAPSE) + recon_conf.prompt_recon_creds(ReconSourceType.MSSQL.value) + recon_conf.prompt_recon_creds(ReconSourceType.SYNAPSE.value) def test_store_connection_secrets_exception(mock_workspace_client): From 0d8b166d04d9477a500a0c30f0f185adb9d6a502 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Thu, 20 Nov 2025 16:55:55 +0100 Subject: [PATCH 03/17] remove untested code and spec for snowflake pem --- .../lakebridge/helpers/recon_config_utils.py | 74 ------------------- tests/unit/helpers/test_recon_config_utils.py | 18 ----- 2 files changed, 92 deletions(-) diff --git a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py index 131fbe6060..5e6db02026 100644 --- a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py +++ b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py @@ -3,7 +3,6 @@ from databricks.labs.blueprint.tui import Prompts from databricks.labs.lakebridge.reconcile.constants import ReconSourceType from databricks.sdk import WorkspaceClient -from databricks.sdk.errors.platform import ResourceDoesNotExist logger = logging.getLogger(__name__) @@ -13,79 +12,6 @@ def __init__(self, ws: WorkspaceClient, prompts: Prompts = Prompts()): self._prompts = prompts self._ws = ws - def _scope_exists(self, scope_name: str) -> bool: - scope_exists = scope_name in [scope.name for scope in self._ws.secrets.list_scopes()] - - if not scope_exists: - logger.error( - f"Error: Cannot find Secret Scope: `{scope_name}` in Databricks Workspace." - f"\nUse `remorph configure-secrets` to setup Scope and Secrets" - ) - return False - logger.debug(f"Found Scope: `{scope_name}` in Databricks Workspace") - return True - - def _ensure_scope_exists(self, scope_name: str): - """ - Get or Create a new Scope in Databricks Workspace - :param scope_name: - """ - scope_exists = self._scope_exists(scope_name) - if not scope_exists: - allow_scope_creation = self._prompts.confirm("Do you want to create a new one?") - if not allow_scope_creation: - msg = "Scope is needed to store Secrets in Databricks Workspace" - raise SystemExit(msg) - - try: - logger.debug(f" Creating a new Scope: `{scope_name}`") - self._ws.secrets.create_scope(scope_name) - except Exception as ex: - logger.error(f"Exception while creating Scope `{scope_name}`: {ex}") - raise ex - - logger.info(f" Created a new Scope: `{scope_name}`") - logger.info(f" Using Scope: `{scope_name}`...") - - def _secret_key_exists(self, scope_name: str, secret_key: str) -> bool: - try: - self._ws.secrets.get_secret(scope_name, secret_key) - logger.info(f"Found Secret key `{secret_key}` in Scope `{scope_name}`") - return True - except ResourceDoesNotExist: - logger.debug(f"Secret key `{secret_key}` not found in Scope `{scope_name}`") - return False - - def _store_secret(self, scope_name: str, secret_key: str, secret_value: str): - try: - logger.debug(f"Storing Secret: *{secret_key}* in Scope: `{scope_name}`") - self._ws.secrets.put_secret(scope=scope_name, key=secret_key, string_value=secret_value) - except Exception as ex: - logger.error(f"Exception while storing Secret `{secret_key}`: {ex}") - raise ex - - def store_connection_secrets(self, scope_name: str, conn_details: tuple[str, dict[str, str]]): - engine = conn_details[0] - secrets = conn_details[1] - - logger.debug(f"Storing `{engine}` Connection Secrets in Scope: `{scope_name}`") - - for key, value in secrets.items(): - secret_key = key - logger.debug(f"Processing Secret: *{secret_key}*") - debug_op = "Storing" - info_op = "Stored" - if self._secret_key_exists(scope_name, secret_key): - overwrite_secret = self._prompts.confirm(f"Do you want to overwrite `{secret_key}`?") - if not overwrite_secret: - continue - debug_op = "Overwriting" - info_op = "Overwritten" - - logger.debug(f"{debug_op} Secret: *{secret_key}* in Scope: `{scope_name}`") - self._store_secret(scope_name, secret_key, value) - logger.info(f"{info_op} Secret: *{secret_key}* in Scope: `{scope_name}`") - def _prompt_snowflake_connection_details(self) -> dict[str, str]: """ Prompt for Snowflake connection details diff --git a/tests/unit/helpers/test_recon_config_utils.py b/tests/unit/helpers/test_recon_config_utils.py index 8296f6e3f8..7c64b0f8e3 100644 --- a/tests/unit/helpers/test_recon_config_utils.py +++ b/tests/unit/helpers/test_recon_config_utils.py @@ -1,8 +1,6 @@ -import pytest from databricks.labs.blueprint.tui import MockPrompts from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts -from databricks.sdk.errors.platform import ResourceDoesNotExist from databricks.labs.lakebridge.reconcile.constants import ReconSourceType @@ -78,19 +76,3 @@ def test_configure_secrets_tsql(mock_workspace_client): recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) recon_conf.prompt_recon_creds(ReconSourceType.MSSQL.value) recon_conf.prompt_recon_creds(ReconSourceType.SYNAPSE.value) - - -def test_store_connection_secrets_exception(mock_workspace_client): - prompts = MockPrompts( - { - r"Do you want to overwrite `source_key`?": "no", - } - ) - - mock_workspace_client.secrets.get_secret.side_effect = ResourceDoesNotExist("Not Found") - mock_workspace_client.secrets.put_secret.side_effect = Exception("Timed out") - - recon_conf = ReconConfigPrompts(mock_workspace_client, prompts) - - with pytest.raises(Exception, match="Timed out"): - recon_conf.store_connection_secrets("scope_name", ("source", {"key": "value"})) From eee23bf87e8822c167556b57ab19119d3681148e Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Thu, 20 Nov 2025 17:41:14 +0100 Subject: [PATCH 04/17] remove empty line --- tests/unit/helpers/test_recon_config_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/helpers/test_recon_config_utils.py b/tests/unit/helpers/test_recon_config_utils.py index 7c64b0f8e3..39cfa34161 100644 --- a/tests/unit/helpers/test_recon_config_utils.py +++ b/tests/unit/helpers/test_recon_config_utils.py @@ -1,4 +1,3 @@ - from databricks.labs.blueprint.tui import MockPrompts from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts From d375e4f73ec460604d398ce9fab8650969109da3 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Thu, 20 Nov 2025 19:16:03 +0100 Subject: [PATCH 05/17] handle optional snowflake prompts correctly --- .../labs/lakebridge/helpers/recon_config_utils.py | 14 +++++++++----- tests/unit/helpers/test_recon_config_utils.py | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py index 5e6db02026..060bb73439 100644 --- a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py +++ b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py @@ -24,13 +24,17 @@ def _prompt_snowflake_connection_details(self) -> dict[str, str]: sf_url = self._prompts.question("Enter Snowflake URL Secret") sf_user = self._prompts.question("Enter User Secret") password_dict = {} - sf_password = self._prompts.question("Enter Password Secret or leave empty to use key-based auth") - if not sf_password: + sf_password = self._prompts.question( + "Enter Password Secret or use `None` to use key-based auth", default="None" + ) + if sf_password.lower() == "none": logger.info("Proceeding with PEM Private Key authentication...") sf_pem_key = self._prompts.question("Enter PEM Private Key Secret") password_dict["pem_private_key"] = sf_pem_key - sf_pem_key_password = self._prompts.question("Enter PEM Private Key Password Secret or leave empty") - if sf_pem_key_password: + sf_pem_key_password = self._prompts.question( + "Enter PEM Private Key Password Secret or use `None`", default="None" + ) + if sf_pem_key_password.lower() == "none": password_dict["pem_private_key_password"] = sf_pem_key_password else: password_dict["sfPassword"] = sf_password @@ -108,7 +112,7 @@ def _connection_details(self, source: str): def prompt_recon_creds(self, source: str) -> tuple[str, dict[str, str]]: logger.info( - "\n(local | env | databricks) \nlocal means values are read as plain text \nenv means values are read " + "\nChoose vault type (local | env | databricks) \nlocal means values are read as plain text \nenv means values are read " "from environment variables fall back to plain text if not variable is not found\ndatabricks means values are read from Databricks Secrets\n", ) secret_vault_type = str( diff --git a/tests/unit/helpers/test_recon_config_utils.py b/tests/unit/helpers/test_recon_config_utils.py index 39cfa34161..8519f7aa09 100644 --- a/tests/unit/helpers/test_recon_config_utils.py +++ b/tests/unit/helpers/test_recon_config_utils.py @@ -27,9 +27,9 @@ def test_configure_secrets_snowflake_pem(mock_workspace_client): r"Enter secret vault type": "0", r"Enter Snowflake URL": "dummy", r"Enter User": "dummy", - r"Enter Password*": "", + r"Enter Password*": "none", r"Enter PEM*": "dummy", - r"Enter PEM*Password*": "dummy", + r"Enter PEM*Password*": "none", r"Enter Database": "dummy", r"Enter Schema": "dummy", r"Enter Snowflake Warehouse": "dummy", From 412c7486a412fe91f571975239738125df09baf0 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Fri, 21 Nov 2025 14:55:50 +0100 Subject: [PATCH 06/17] add docs --- .../docs/reconcile/recon_notebook.mdx | 36 ++++++++++++++++--- .../docs/reconcile/reconcile_automation.mdx | 2 +- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/lakebridge/docs/reconcile/recon_notebook.mdx b/docs/lakebridge/docs/reconcile/recon_notebook.mdx index b2203bbd08..fb8ea71c34 100644 --- a/docs/lakebridge/docs/reconcile/recon_notebook.mdx +++ b/docs/lakebridge/docs/reconcile/recon_notebook.mdx @@ -72,12 +72,13 @@ class ReconcileConfig: secret_scope: str database_config: DatabaseConfig metadata_config: ReconcileMetadataConfig + creds_or_secret_scope: ReconcileCredentialConfig | str | None = None ``` Parameters: - `data_source`: The data source to be reconciled. Supported values: `snowflake`, `teradata`, `oracle`, `mssql`, `synapse`, `databricks`. - `report_type`: The type of report to be generated. Available report types are `schema`, `row`, `data` or `all`. For details check [here](./dataflow_example.mdx). -- `secret_scope`: The secret scope name used to store the connection credentials for the source database system. +- `secret_scope`: (Deprecated in favor of `creds_or_secret_scope` and kept for backwards compatibility) The secret scope name used to store the connection credentials for the source database system. - `database_config`: The database configuration for connecting to the source database. expects a `DatabaseConfig` object. - `source_schema`: The source schema name. - `target_catalog`: The target catalog name. @@ -104,6 +105,16 @@ class ReconcileMetadataConfig: ``` If not set the default values will be used to store the metadata. The default resources are created during the installation of Lakebridge. +- `creds_or_secret_scope`: The credentials to use to connect to the data source. Made optional for backwards compatibility. +Can also be a string having value of secret scope to mimic old behavior of credentials. If used, `secret_scope` will be ignored. + - `vault_type`: Can be local to use the values directly, env to load from env variables or databricks to load from databricks secrets. + - `source_creds`: A mapping of reconcile credentials keys to the values that will be resolved depending on vault type. +```python +@dataclass +class ReconcileCredentialConfig: + vault_type: str + source_creds: dict[str, str] +``` An Example of configuring the Reconcile properties: @@ -111,13 +122,14 @@ An Example of configuring the Reconcile properties: from databricks.labs.lakebridge.config import ( DatabaseConfig, ReconcileConfig, - ReconcileMetadataConfig + ReconcileMetadataConfig, + ReconcileCredentialConfig ) reconcile_config = ReconcileConfig( data_source = "snowflake", report_type = "all", - secret_scope = "snowflake-credential", + secret_scope = "NOT_USED", database_config= DatabaseConfig(source_catalog="source_sf_catalog", source_schema="source_sf_schema", target_catalog="target_databricks_catalog", @@ -126,9 +138,25 @@ reconcile_config = ReconcileConfig( metadata_config = ReconcileMetadataConfig( catalog = "lakebridge_metadata", schema= "reconcile" - ) + ), + creds_or_secret_scope=ReconcileCredentialConfig( + vault_type="local", + source_creds={"sfUrl": "xxx@snowflakecomputing.com", "sfUser": "app", "sfPassword": "the P@asswort", "sfRole": "app"} + ) ) ``` +An Example of using databricks secrets for the source credentials: +```python +reconcile_config = ReconcileConfig( + ..., + creds_or_secret_scope=ReconcileCredentialConfig( + vault_type="databricks", + source_creds={"sfUrl": "some_secret_scope/some_key", "sfUser": "another_secret_scope/user_key", "sfPassword": "scope/key", "sfRole": "scope/key"} + ) +) + +``` +All the expected credentials have to be configured. ## Configure Table Properties diff --git a/docs/lakebridge/docs/reconcile/reconcile_automation.mdx b/docs/lakebridge/docs/reconcile/reconcile_automation.mdx index 80fc668940..0d62825a1f 100644 --- a/docs/lakebridge/docs/reconcile/reconcile_automation.mdx +++ b/docs/lakebridge/docs/reconcile/reconcile_automation.mdx @@ -116,7 +116,7 @@ To run the utility, the following parameters must be set: - `remorph_catalog`: The catalog configured through CLI. - `remorph_schema`: The schema configured through CLI. - `remorph_config_table`: The table configs created as a part of the pre-requisites. -- `secret_scope`: The Databricks secret scope for accessing the source system. Refer to the Lakebridge documentation for the specific keys required to be configured as per the source system. +- `secret_scope`: (Deprecated) The Databricks secret scope for accessing the source system. Refer to the Lakebridge documentation for the specific keys required to be configured as per the source system. - `source_system`: The source system against which reconciliation is performed. - `table_recon_summary`: The target summary table created as a part of the pre-requisites. From f97e06ab6b4ee9d54f7614377a3520e788fc12b0 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Fri, 21 Nov 2025 15:36:23 +0100 Subject: [PATCH 07/17] add more examples --- docs/lakebridge/docs/reconcile/index.mdx | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/lakebridge/docs/reconcile/index.mdx b/docs/lakebridge/docs/reconcile/index.mdx index 8a111d5162..8c6bde476d 100644 --- a/docs/lakebridge/docs/reconcile/index.mdx +++ b/docs/lakebridge/docs/reconcile/index.mdx @@ -38,6 +38,76 @@ Refer to [Reconcile Configuration Guide](reconcile_configuration) for detailed i > 2. Setup the connection properties +#### Option A: Using Lakebridge credentials mechanism +Reconcile connection properties are configured through a dynamic mapping from connection property to value. +The values can be loaded from databricks, env vars or used directly. It depends on the config in `reconcile.yml` +```yaml +... +creds_or_secret_scope: + vault_type: local + source_creds: + +``` +or to use databricks secrets. And the value has to be in the form of `/` +```yaml +... +creds_or_secret_scope: + vault_type: databricks + source_creds: + some_property = / + ... +``` +The expected connection properties under `source_creds` per data source are: + + + ```yaml + sfUrl = [local_or_databricks_mapping] + account = [local_or_databricks_mapping] + sfUser = [local_or_databricks_mapping] + sfPassword = [local_or_databricks_mapping] + sfDatabase = [local_or_databricks_mapping] + sfSchema = [local_or_databricks_mapping] + sfWarehouse = [local_or_databricks_mapping] + sfRole = [local_or_databricks_mapping] + pem_private_key = [local_or_databricks_mapping] + pem_private_key_password = [local_or_databricks_mapping] + ``` + + :::note + For Snowflake authentication, either sfPassword or pem_private_key is required. + Priority is given to pem_private_key, and if it is not found, sfPassword will be used. + If neither is available, an exception will be raised. + + When using an encrypted pem_private_key, you'll need to provide the pem_private_key_password. + This password is used to decrypt the private key for authentication. + ::: + + + ```yaml + user = [local_or_databricks_mapping] + password = [local_or_databricks_mapping] + host = [local_or_databricks_mapping] + port = [local_or_databricks_mapping] + database = [local_or_databricks_mapping] + ``` + + + ```yaml + user = [local_or_databricks_mapping] + password = [local_or_databricks_mapping] + host = [local_or_databricks_mapping] + port = [local_or_databricks_mapping] + database = [local_or_databricks_mapping] + encrypt = [local_or_databricks_mapping] + trustServerCertificate = [local_or_databricks_mapping] + ``` + + +#### Option B: Using secret scopes +:::warning +Deprecated in favor of Lakebridge credentials mechanism +::: + Lakebridge-Reconcile manages connection properties by utilizing secrets stored in the Databricks workspace. Below is the default secret naming convention for managing connection properties. From 9f41a68416e7f4f7e3f2794b02c807d8e54eef91 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Mon, 24 Nov 2025 13:01:47 +0100 Subject: [PATCH 08/17] update doc --- docs/lakebridge/docs/reconcile/index.mdx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/lakebridge/docs/reconcile/index.mdx b/docs/lakebridge/docs/reconcile/index.mdx index 8c6bde476d..e5da59035a 100644 --- a/docs/lakebridge/docs/reconcile/index.mdx +++ b/docs/lakebridge/docs/reconcile/index.mdx @@ -136,17 +136,11 @@ Below are the connection properties required for each source: sfSchema = [schema] sfWarehouse = [warehouse_name] sfRole = [role_name] - pem_private_key = [pkcs8_pem_private_key] - pem_private_key_password = [pkcs8_pem_private_key] ``` :::note - For Snowflake authentication, either sfPassword or pem_private_key is required. - Priority is given to pem_private_key, and if it is not found, sfPassword will be used. - If neither is available, an exception will be raised. - - When using an encrypted pem_private_key, you'll need to provide the pem_private_key_password. - This password is used to decrypt the private key for authentication. + For Snowflake authentication, sfPassword is required. To use pem_private_key, + and optionally pem_private_key_password, please use the Lakebridge credentials mechanism. ::: From fac9298924fa69247f03b5d202cc18f4b0823897 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Mon, 24 Nov 2025 15:09:00 +0100 Subject: [PATCH 09/17] mege conflicts --- src/databricks/labs/lakebridge/install.py | 7 ++++--- tests/unit/test_install.py | 7 +------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/databricks/labs/lakebridge/install.py b/src/databricks/labs/lakebridge/install.py index 2946c7b53f..43f4fa0f5e 100644 --- a/src/databricks/labs/lakebridge/install.py +++ b/src/databricks/labs/lakebridge/install.py @@ -329,10 +329,11 @@ def _prompt_for_new_reconcile_installation(self) -> ReconcileConfig: report_type = self._prompts.choice( "Select the report type:", [report_type.value for report_type in ReconReportType] ) - creds_or_secret_scope: str | ReconcileCredentialConfig = "NOT_USED" if data_source != ReconSourceType.DATABRICKS.value: vault, credentials = self._recon_creds_prompts.prompt_recon_creds(data_source) - creds_or_secret_scope = ReconcileCredentialConfig(vault, credentials) + creds = ReconcileCredentialConfig(vault, credentials) + else: + creds = ReconcileCredentialConfig("n/a", {}) db_config = self._prompt_for_reconcile_database_config(data_source) metadata_config = self._prompt_for_reconcile_metadata_config() @@ -340,7 +341,7 @@ def _prompt_for_new_reconcile_installation(self) -> ReconcileConfig: return ReconcileConfig( data_source=data_source, report_type=report_type, - creds=creds_or_secret_scope, + creds=creds, database_config=db_config, metadata_config=metadata_config, ) diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index 0ba617e9cb..addf1a6692 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -689,7 +689,6 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work schema="reconcile", volume="reconcile_volume", ), - creds_or_secret_scope=creds_sample, ), transpile=None, ) @@ -765,9 +764,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No reconcile=ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig( - vault_type="databricks", source_creds={"__secret_scope": "NOT_USED"} - ), + creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "NOT_USED"}), database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -779,7 +776,6 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No schema="reconcile", volume="reconcile_volume", ), - creds_or_secret_scope=creds_sample, ), transpile=None, ) @@ -923,7 +919,6 @@ def test_configure_all_override_installation( schema="reconcile", volume="reconcile_volume", ), - creds_or_secret_scope=creds_sample, ) expected_config = LakebridgeConfiguration(transpile=expected_transpile_config, reconcile=expected_reconcile_config) assert config == expected_config From 6513ff587632d0e1f4c33c352baa4469fd50653c Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Mon, 24 Nov 2025 15:18:33 +0100 Subject: [PATCH 10/17] fix tests after merge conflicts --- tests/unit/test_install.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index addf1a6692..56e1240b58 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -678,7 +678,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work reconcile=ReconcileConfig( data_source="oracle", report_type="all", - creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "NOT_USED"}), + creds=ReconcileCredentialConfig(vault_type="local", source_creds={"test_secret": "dummy"}), database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -698,10 +698,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work { "data_source": "oracle", "report_type": "all", - "creds": { - "vault_type": "databricks", - "source_creds": {"__secret_scope": "NOT_USED"}, - }, + "creds": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "database_config": { "source_schema": "tpch_sf1000", "target_catalog": "tpch", @@ -712,7 +709,6 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work "schema": "reconcile", "volume": "reconcile_volume", }, - "creds_or_secret_scope": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "version": 2, }, ) @@ -764,7 +760,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No reconcile=ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "NOT_USED"}), + creds=ReconcileCredentialConfig(vault_type="local", source_creds={"test_secret": "dummy"}), database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -785,10 +781,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No { "data_source": "snowflake", "report_type": "all", - "creds": { - "vault_type": "databricks", - "source_creds": {"__secret_scope": "NOT_USED"}, - }, + "creds": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "database_config": { "source_catalog": "snowflake_sample_data", "source_schema": "tpch_sf1000", @@ -800,7 +793,6 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No "schema": "reconcile", "volume": "reconcile_volume", }, - "creds_or_secret_scope": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "version": 2, }, ) @@ -907,7 +899,7 @@ def test_configure_all_override_installation( expected_reconcile_config = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "NOT_USED"}), + creds=ReconcileCredentialConfig(vault_type="local", source_creds={"test_secret": "dummy"}), database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -942,10 +934,7 @@ def test_configure_all_override_installation( { "data_source": "snowflake", "report_type": "all", - "creds": { - "vault_type": "databricks", - "source_creds": {"__secret_scope": "NOT_USED"}, - }, + "creds": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "database_config": { "source_catalog": "snowflake_sample_data", "source_schema": "tpch_sf1000", @@ -957,7 +946,6 @@ def test_configure_all_override_installation( "schema": "reconcile", "volume": "reconcile_volume", }, - "creds_or_secret_scope": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, "version": 2, }, ) From 6baf386e60fef69dba5fc08847ea8be0d4d67650 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Thu, 4 Dec 2025 15:49:53 +0100 Subject: [PATCH 11/17] minor fixes after merge --- src/databricks/labs/lakebridge/install.py | 2 +- tests/unit/test_install.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/databricks/labs/lakebridge/install.py b/src/databricks/labs/lakebridge/install.py index 372a8ed7dc..552c971f63 100644 --- a/src/databricks/labs/lakebridge/install.py +++ b/src/databricks/labs/lakebridge/install.py @@ -333,7 +333,7 @@ def _prompt_for_new_reconcile_installation(self) -> ReconcileConfig: vault, credentials = self._recon_creds_prompts.prompt_recon_creds(data_source) creds = ReconcileCredentialConfig(vault, credentials) else: - creds = ReconcileCredentialConfig("n/a", {}) + creds = ReconcileCredentialConfig("databricks", {}) db_config = self._prompt_for_reconcile_database_config(data_source) metadata_config = self._prompt_for_reconcile_metadata_config() diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index a607bda3f4..802ee7c785 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -803,7 +803,6 @@ def test_configure_reconcile_databricks_no_existing_installation(ws: WorkspaceCl prompts = MockPrompts( { r"Select the Data Source": str(RECONCILE_DATA_SOURCES.index("databricks")), - r"Enter Secret scope name to store .* connection details / secrets": "remorph_databricks", r"Select the report type": str(RECONCILE_REPORT_TYPES.index("all")), r"Enter source catalog name for .*": "databricks_catalog", r"Enter source schema name for .*": "some_schema", @@ -826,6 +825,7 @@ def test_configure_reconcile_databricks_no_existing_installation(ws: WorkspaceCl workspace_installation=create_autospec(WorkspaceInstallation), ) + creds_mock = MagicMock(ReconConfigPrompts) # user not prompted if databricks source workspace_installer = WorkspaceInstaller( ctx.workspace_client, ctx.prompts, @@ -833,6 +833,7 @@ def test_configure_reconcile_databricks_no_existing_installation(ws: WorkspaceCl ctx.install_state, ctx.product_info, ctx.resource_configurator, + creds_mock, ctx.workspace_installation, ) config = workspace_installer.configure(module="reconcile") @@ -853,7 +854,8 @@ def test_configure_reconcile_databricks_no_existing_installation(ws: WorkspaceCl volume="reconcile_volume", ), creds=ReconcileCredentialConfig( - vault_type="databricks", source_creds={"__secret_scope": "remorph_databricks"} + vault_type="databricks", + source_creds={}, ), ), transpile=None, @@ -866,7 +868,6 @@ def test_configure_reconcile_databricks_no_existing_installation(ws: WorkspaceCl "report_type": "all", "creds": { "vault_type": "databricks", - "source_creds": {"__secret_scope": "remorph_databricks"}, }, "database_config": { "source_catalog": "databricks_catalog", From 4656bb80bf17315b19e5e1147612280fbf400b58 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Mon, 8 Dec 2025 18:04:29 +0100 Subject: [PATCH 12/17] support databricks vault only --- .../lakebridge/helpers/recon_config_utils.py | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py index 060bb73439..236d9fb16e 100644 --- a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py +++ b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py @@ -21,27 +21,27 @@ def _prompt_snowflake_connection_details(self) -> dict[str, str]: f"Please answer a couple of questions to configure `{ReconSourceType.SNOWFLAKE.value}` Connection profile" ) - sf_url = self._prompts.question("Enter Snowflake URL Secret") - sf_user = self._prompts.question("Enter User Secret") + sf_url = self._prompts.question("Enter Snowflake URL Secret Name") + sf_user = self._prompts.question("Enter User Secret Name") password_dict = {} sf_password = self._prompts.question( - "Enter Password Secret or use `None` to use key-based auth", default="None" + "Enter Password Secret Name or use `None` to use key-based auth", default="None" ) if sf_password.lower() == "none": logger.info("Proceeding with PEM Private Key authentication...") - sf_pem_key = self._prompts.question("Enter PEM Private Key Secret") + sf_pem_key = self._prompts.question("Enter PEM Private Key Secret Name") password_dict["pem_private_key"] = sf_pem_key sf_pem_key_password = self._prompts.question( - "Enter PEM Private Key Password Secret or use `None`", default="None" + "Enter PEM Private Key Password Secret Name or use `None`", default="None" ) if sf_pem_key_password.lower() == "none": password_dict["pem_private_key_password"] = sf_pem_key_password else: password_dict["sfPassword"] = sf_password - sf_db = self._prompts.question("Enter Database Secret") - sf_schema = self._prompts.question("Enter Schema Secret") - sf_warehouse = self._prompts.question("Enter Snowflake Warehouse Secret") - sf_role = self._prompts.question("Enter Role Secret") + sf_db = self._prompts.question("Enter Database Secret Name") + sf_schema = self._prompts.question("Enter Schema Secret Name") + sf_warehouse = self._prompts.question("Enter Snowflake Warehouse Secret Name") + sf_role = self._prompts.question("Enter Role Secret Name") sf_conn_details = { "sfUrl": sf_url, @@ -62,11 +62,11 @@ def _prompt_oracle_connection_details(self) -> dict[str, str]: logger.info( f"Please answer a couple of questions to configure `{ReconSourceType.ORACLE.value}` Connection profile" ) - user = self._prompts.question("Enter User Secret") - password = self._prompts.question("Enter Password Secret") - host = self._prompts.question("Enter host Secret") - port = self._prompts.question("Enter port Secret") - database = self._prompts.question("Enter database/SID Secret") + user = self._prompts.question("Enter User Secret Name") + password = self._prompts.question("Enter Password Secret Name") + host = self._prompts.question("Enter host Secret Name") + port = self._prompts.question("Enter port Secret Name") + database = self._prompts.question("Enter database/SID Secret Name") oracle_conn_details = {"user": user, "password": password, "host": host, "port": port, "database": database} @@ -80,13 +80,13 @@ def _prompt_mssql_connection_details(self) -> dict[str, str]: logger.info( f"Please answer a couple of questions to configure `{ReconSourceType.MSSQL.value}`/`{ReconSourceType.SYNAPSE.value}` Connection profile" ) - user = self._prompts.question("Enter User Secret") - password = self._prompts.question("Enter Password Secret") - host = self._prompts.question("Enter host Secret") - port = self._prompts.question("Enter port Secret") - database = self._prompts.question("Enter database Secret") - encrypt = self._prompts.question("Enter Encrypt Secret") - trust_server_certificate = self._prompts.question("Enter Trust Server Certificate Secret") + user = self._prompts.question("Enter User Secret Name") + password = self._prompts.question("Enter Password Secret Name") + host = self._prompts.question("Enter host Secret Name") + port = self._prompts.question("Enter port Secret Name") + database = self._prompts.question("Enter database Secret Name") + encrypt = self._prompts.question("Enter Encrypt Secret Name") + trust_server_certificate = self._prompts.question("Enter Trust Server Certificate Secret Name") tsql_conn_details = { "user": user, @@ -111,18 +111,6 @@ def _connection_details(self, source: str): return self._prompt_mssql_connection_details() def prompt_recon_creds(self, source: str) -> tuple[str, dict[str, str]]: - logger.info( - "\nChoose vault type (local | env | databricks) \nlocal means values are read as plain text \nenv means values are read " - "from environment variables fall back to plain text if not variable is not found\ndatabricks means values are read from Databricks Secrets\n", - ) - secret_vault_type = str( - self._prompts.choice("Enter secret vault type (local | env | databricks)", ["local", "env", "databricks"]) - ).lower() - - if secret_vault_type == "databricks": - logger.info( - "Since you have chosen `databricks` as secret vault type, you need to provide secret names in the following steps in the format /" - ) - + logger.info("Please provide secret names in the following steps in the format /") connection_details = self._connection_details(source) - return secret_vault_type, connection_details + return "databricks", connection_details From b20fb8f4e97d63f5832a35597e2be0b24e644d2f Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Mon, 15 Dec 2025 12:06:19 +0100 Subject: [PATCH 13/17] rename class and property --- docs/lakebridge/docs/reconcile/index.mdx | 6 ++-- .../docs/reconcile/recon_notebook.mdx | 18 +++++------ src/databricks/labs/lakebridge/config.py | 8 ++--- src/databricks/labs/lakebridge/install.py | 6 ++-- .../reconcile/trigger_recon_service.py | 2 +- .../reconcile/query_builder/test_execute.py | 14 ++++----- .../reconcile/test_oracle_reconcile.py | 4 +-- tests/unit/deployment/test_installation.py | 8 ++--- tests/unit/deployment/test_job.py | 6 ++-- tests/unit/deployment/test_recon.py | 6 ++-- tests/unit/test_install.py | 30 +++++++++---------- 11 files changed, 54 insertions(+), 54 deletions(-) diff --git a/docs/lakebridge/docs/reconcile/index.mdx b/docs/lakebridge/docs/reconcile/index.mdx index e5da59035a..cedbeadc13 100644 --- a/docs/lakebridge/docs/reconcile/index.mdx +++ b/docs/lakebridge/docs/reconcile/index.mdx @@ -45,7 +45,7 @@ The values can be loaded from databricks, env vars or used directly. It depends ... creds_or_secret_scope: vault_type: local - source_creds: + vault_secret_names: ``` or to use databricks secrets. And the value has to be in the form of `/` @@ -53,11 +53,11 @@ or to use databricks secrets. And the value has to be in the form of `/ ... ``` -The expected connection properties under `source_creds` per data source are: +The expected connection properties under `vault_secret_names` per data source are: ```yaml diff --git a/docs/lakebridge/docs/reconcile/recon_notebook.mdx b/docs/lakebridge/docs/reconcile/recon_notebook.mdx index fb8ea71c34..e9ec779dba 100644 --- a/docs/lakebridge/docs/reconcile/recon_notebook.mdx +++ b/docs/lakebridge/docs/reconcile/recon_notebook.mdx @@ -72,7 +72,7 @@ class ReconcileConfig: secret_scope: str database_config: DatabaseConfig metadata_config: ReconcileMetadataConfig - creds_or_secret_scope: ReconcileCredentialConfig | str | None = None + creds_or_secret_scope: ReconcileCredentialsConfig | str | None = None ``` Parameters: @@ -108,12 +108,12 @@ of Lakebridge. - `creds_or_secret_scope`: The credentials to use to connect to the data source. Made optional for backwards compatibility. Can also be a string having value of secret scope to mimic old behavior of credentials. If used, `secret_scope` will be ignored. - `vault_type`: Can be local to use the values directly, env to load from env variables or databricks to load from databricks secrets. - - `source_creds`: A mapping of reconcile credentials keys to the values that will be resolved depending on vault type. + - `vault_secret_names`: A mapping of reconcile credentials keys to the values that will be resolved depending on vault type. ```python @dataclass -class ReconcileCredentialConfig: +class ReconcileCredentialsConfig: vault_type: str - source_creds: dict[str, str] + vault_secret_names: dict[str, str] ``` An Example of configuring the Reconcile properties: @@ -123,7 +123,7 @@ from databricks.labs.lakebridge.config import ( DatabaseConfig, ReconcileConfig, ReconcileMetadataConfig, - ReconcileCredentialConfig + ReconcileCredentialsConfig ) reconcile_config = ReconcileConfig( @@ -139,9 +139,9 @@ reconcile_config = ReconcileConfig( catalog = "lakebridge_metadata", schema= "reconcile" ), - creds_or_secret_scope=ReconcileCredentialConfig( + creds_or_secret_scope=ReconcileCredentialsConfig( vault_type="local", - source_creds={"sfUrl": "xxx@snowflakecomputing.com", "sfUser": "app", "sfPassword": "the P@asswort", "sfRole": "app"} + vault_secret_names={"sfUrl": "xxx@snowflakecomputing.com", "sfUser": "app", "sfPassword": "the P@asswort", "sfRole": "app"} ) ) ``` @@ -149,9 +149,9 @@ An Example of using databricks secrets for the source credentials: ```python reconcile_config = ReconcileConfig( ..., - creds_or_secret_scope=ReconcileCredentialConfig( + creds_or_secret_scope=ReconcileCredentialsConfig( vault_type="databricks", - source_creds={"sfUrl": "some_secret_scope/some_key", "sfUser": "another_secret_scope/user_key", "sfPassword": "scope/key", "sfRole": "scope/key"} + vault_secret_names={"sfUrl": "some_secret_scope/some_key", "sfUser": "another_secret_scope/user_key", "sfPassword": "scope/key", "sfRole": "scope/key"} ) ) diff --git a/src/databricks/labs/lakebridge/config.py b/src/databricks/labs/lakebridge/config.py index 0b39b64838..4bab3e0c3a 100644 --- a/src/databricks/labs/lakebridge/config.py +++ b/src/databricks/labs/lakebridge/config.py @@ -252,9 +252,9 @@ class ReconcileMetadataConfig: @dataclass -class ReconcileCredentialConfig: +class ReconcileCredentialsConfig: vault_type: str - source_creds: dict[str, str] + vault_secret_names: dict[str, str] def __post_init__(self): if self.vault_type not in {"local", "env", "databricks"}: @@ -268,7 +268,7 @@ class ReconcileConfig: data_source: str report_type: str - creds: ReconcileCredentialConfig + creds: ReconcileCredentialsConfig database_config: DatabaseConfig metadata_config: ReconcileMetadataConfig @@ -276,7 +276,7 @@ class ReconcileConfig: def v1_migrate(cls, raw: dict[str, JsonValue]) -> dict[str, JsonValue]: secret_scope = raw.pop("secret_scope") raw["version"] = 2 - raw["creds"] = {"vault_type": "databricks", "source_creds": {"__secret_scope": secret_scope}} + raw["creds"] = {"vault_type": "databricks", "vault_secret_names": {"__secret_scope": secret_scope}} return raw diff --git a/src/databricks/labs/lakebridge/install.py b/src/databricks/labs/lakebridge/install.py index 552c971f63..0f5e607eae 100644 --- a/src/databricks/labs/lakebridge/install.py +++ b/src/databricks/labs/lakebridge/install.py @@ -20,7 +20,7 @@ LakebridgeConfiguration, ReconcileMetadataConfig, TranspileConfig, - ReconcileCredentialConfig, + ReconcileCredentialsConfig, ) from databricks.labs.lakebridge.contexts.application import ApplicationContext from databricks.labs.lakebridge.deployment.configurator import ResourceConfigurator @@ -331,9 +331,9 @@ def _prompt_for_new_reconcile_installation(self) -> ReconcileConfig: ) if data_source != ReconSourceType.DATABRICKS.value: vault, credentials = self._recon_creds_prompts.prompt_recon_creds(data_source) - creds = ReconcileCredentialConfig(vault, credentials) + creds = ReconcileCredentialsConfig(vault, credentials) else: - creds = ReconcileCredentialConfig("databricks", {}) + creds = ReconcileCredentialsConfig("databricks", {}) db_config = self._prompt_for_reconcile_database_config(data_source) metadata_config = self._prompt_for_reconcile_metadata_config() diff --git a/src/databricks/labs/lakebridge/reconcile/trigger_recon_service.py b/src/databricks/labs/lakebridge/reconcile/trigger_recon_service.py index ffa5184319..ae631fda49 100644 --- a/src/databricks/labs/lakebridge/reconcile/trigger_recon_service.py +++ b/src/databricks/labs/lakebridge/reconcile/trigger_recon_service.py @@ -74,7 +74,7 @@ def create_recon_dependencies( engine=reconcile_config.data_source, spark=spark, ws=ws_client, - secret_scope=reconcile_config.creds.source_creds["__secret_scope"], + secret_scope=reconcile_config.creds.vault_secret_names["__secret_scope"], ) recon_id = str(uuid4()) diff --git a/tests/integration/reconcile/query_builder/test_execute.py b/tests/integration/reconcile/query_builder/test_execute.py index 07335dd3e6..03ba0e00e0 100644 --- a/tests/integration/reconcile/query_builder/test_execute.py +++ b/tests/integration/reconcile/query_builder/test_execute.py @@ -13,7 +13,7 @@ TableRecon, ReconcileMetadataConfig, ReconcileConfig, - ReconcileCredentialConfig, + ReconcileCredentialsConfig, ) from databricks.labs.lakebridge.reconcile.reconciliation import Reconciliation from databricks.labs.lakebridge.reconcile.trigger_recon_service import TriggerReconService @@ -732,7 +732,7 @@ def mock_for_report_type_data( reconcile_config_data = ReconcileConfig( data_source="databricks", report_type="data", - creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="databricks", vault_secret_names={"__secret_scope": "fake"}), database_config=DatabaseConfig( source_catalog=CATALOG, source_schema=SCHEMA, @@ -929,7 +929,7 @@ def mock_for_report_type_schema( reconcile_config_schema = ReconcileConfig( data_source="databricks", report_type="schema", - creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="databricks", vault_secret_names={"__secret_scope": "fake"}), database_config=DatabaseConfig( source_catalog=CATALOG, source_schema=SCHEMA, @@ -1141,7 +1141,7 @@ def mock_for_report_type_all( reconcile_config_all = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_catalog=CATALOG, source_schema=SCHEMA, @@ -1416,7 +1416,7 @@ def mock_for_report_type_row( reconcile_config_row = ReconcileConfig( data_source="snowflake", report_type="row", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_catalog=CATALOG, source_schema=SCHEMA, @@ -1562,7 +1562,7 @@ def mock_for_recon_exception(normalized_table_conf_with_opts, setup_metadata_tab reconcile_config_exception = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="databricks", vault_secret_names={"__secret_scope": "fake"}), database_config=DatabaseConfig( source_catalog=CATALOG, source_schema=SCHEMA, @@ -2022,7 +2022,7 @@ def test_recon_output_without_exception(mock_gen_final_recon_output): reconcile_config = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="databricks", source_creds={"__secret_scope": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="databricks", vault_secret_names={"__secret_scope": "fake"}), database_config=DatabaseConfig( source_catalog=CATALOG, source_schema=SCHEMA, diff --git a/tests/integration/reconcile/test_oracle_reconcile.py b/tests/integration/reconcile/test_oracle_reconcile.py index 5393eb3ae7..794fb1cbb5 100644 --- a/tests/integration/reconcile/test_oracle_reconcile.py +++ b/tests/integration/reconcile/test_oracle_reconcile.py @@ -8,7 +8,7 @@ DatabaseConfig, ReconcileMetadataConfig, ReconcileConfig, - ReconcileCredentialConfig, + ReconcileCredentialsConfig, ) from databricks.labs.lakebridge.reconcile.connectors.databricks import DatabricksDataSource from databricks.labs.lakebridge.reconcile.recon_capture import ReconCapture @@ -51,7 +51,7 @@ def test_oracle_db_reconcile(mock_spark, mock_workspace_client, tmp_path): reconcile_config = ReconcileConfig( data_source="oracle", report_type=report, - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=db_config, metadata_config=ReconcileMetadataConfig(catalog="tmp", schema="reconcile"), ) diff --git a/tests/unit/deployment/test_installation.py b/tests/unit/deployment/test_installation.py index f13ede1f23..ed789d8cbd 100644 --- a/tests/unit/deployment/test_installation.py +++ b/tests/unit/deployment/test_installation.py @@ -16,7 +16,7 @@ ReconcileConfig, DatabaseConfig, ReconcileMetadataConfig, - ReconcileCredentialConfig, + ReconcileCredentialsConfig, ) from databricks.labs.lakebridge.deployment.installation import WorkspaceInstallation from databricks.labs.lakebridge.deployment.recon import ReconDeployment @@ -56,7 +56,7 @@ def test_install_all(ws): reconcile_config = ReconcileConfig( data_source="oracle", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_schema="tpch_sf10006", target_catalog="tpch6", @@ -111,7 +111,7 @@ def test_recon_component_installation(ws): reconcile_config = ReconcileConfig( data_source="oracle", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_schema="tpch_sf10008", target_catalog="tpch8", @@ -194,7 +194,7 @@ def test_uninstall_configs_exist(ws): reconcile_config = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_catalog="snowflake_sample_data1", source_schema="tpch_sf10001", diff --git a/tests/unit/deployment/test_job.py b/tests/unit/deployment/test_job.py index 39e8849180..96c0b4291d 100644 --- a/tests/unit/deployment/test_job.py +++ b/tests/unit/deployment/test_job.py @@ -13,7 +13,7 @@ ReconcileConfig, DatabaseConfig, ReconcileMetadataConfig, - ReconcileCredentialConfig, + ReconcileCredentialsConfig, ) from databricks.labs.lakebridge.deployment.job import JobDeployment @@ -23,7 +23,7 @@ def oracle_recon_config() -> ReconcileConfig: return ReconcileConfig( data_source="oracle", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_schema="tpch_sf10009", target_catalog="tpch9", @@ -42,7 +42,7 @@ def snowflake_recon_config() -> ReconcileConfig: return ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_schema="tpch_sf10009", target_catalog="tpch9", diff --git a/tests/unit/deployment/test_recon.py b/tests/unit/deployment/test_recon.py index 4eed4862fe..f14c78dbd9 100644 --- a/tests/unit/deployment/test_recon.py +++ b/tests/unit/deployment/test_recon.py @@ -13,7 +13,7 @@ ReconcileConfig, DatabaseConfig, ReconcileMetadataConfig, - ReconcileCredentialConfig, + ReconcileCredentialsConfig, ) from databricks.labs.lakebridge.deployment.dashboard import DashboardDeployment from databricks.labs.lakebridge.deployment.job import JobDeployment @@ -57,7 +57,7 @@ def test_install(ws): reconcile_config = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_catalog="snowflake_sample_data4", source_schema="tpch_sf10004", @@ -150,7 +150,7 @@ def test_uninstall(ws): recon_config = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"fake": "fake"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"fake": "fake"}), database_config=DatabaseConfig( source_catalog="snowflake_sample_data5", source_schema="tpch_sf10005", diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index 802ee7c785..b575692884 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -17,7 +17,7 @@ ReconcileConfig, ReconcileMetadataConfig, TranspileConfig, - ReconcileCredentialConfig, + ReconcileCredentialsConfig, ) from databricks.labs.lakebridge.contexts.application import ApplicationContext from databricks.labs.lakebridge.deployment.configurator import ResourceConfigurator @@ -660,8 +660,8 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work ) creds_mock = MagicMock(ReconConfigPrompts) - creds_sample = ReconcileCredentialConfig("local", {"test_secret": "dummy"}) - creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.source_creds) + creds_sample = ReconcileCredentialsConfig("local", {"test_secret": "dummy"}) + creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.vault_secret_names) workspace_installer = WorkspaceInstaller( ctx.workspace_client, ctx.prompts, @@ -678,7 +678,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work reconcile=ReconcileConfig( data_source="oracle", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"test_secret": "dummy"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"test_secret": "dummy"}), database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -698,7 +698,7 @@ def test_configure_reconcile_installation_config_error_continue_install(ws: Work { "data_source": "oracle", "report_type": "all", - "creds": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, + "creds": {"vault_type": "local", "vault_secret_names": {"test_secret": "dummy"}}, "database_config": { "source_schema": "tpch_sf1000", "target_catalog": "tpch", @@ -742,8 +742,8 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No ) creds_mock = MagicMock(ReconConfigPrompts) - creds_sample = ReconcileCredentialConfig("local", {"test_secret": "dummy"}) - creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.source_creds) + creds_sample = ReconcileCredentialsConfig("local", {"test_secret": "dummy"}) + creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.vault_secret_names) workspace_installer = WorkspaceInstaller( ctx.workspace_client, ctx.prompts, @@ -760,7 +760,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No reconcile=ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"test_secret": "dummy"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"test_secret": "dummy"}), database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -781,7 +781,7 @@ def test_configure_reconcile_no_existing_installation(ws: WorkspaceClient) -> No { "data_source": "snowflake", "report_type": "all", - "creds": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, + "creds": {"vault_type": "local", "vault_secret_names": {"test_secret": "dummy"}}, "database_config": { "source_catalog": "snowflake_sample_data", "source_schema": "tpch_sf1000", @@ -853,9 +853,9 @@ def test_configure_reconcile_databricks_no_existing_installation(ws: WorkspaceCl schema="reconcile", volume="reconcile_volume", ), - creds=ReconcileCredentialConfig( + creds=ReconcileCredentialsConfig( vault_type="databricks", - source_creds={}, + vault_secret_names={}, ), ), transpile=None, @@ -956,8 +956,8 @@ def test_configure_all_override_installation( ) creds_mock = MagicMock(ReconConfigPrompts) - creds_sample = ReconcileCredentialConfig("local", {"test_secret": "dummy"}) - creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.source_creds) + creds_sample = ReconcileCredentialsConfig("local", {"test_secret": "dummy"}) + creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.vault_secret_names) workspace_installer = ws_installer( ctx.workspace_client, ctx.prompts, @@ -986,7 +986,7 @@ def test_configure_all_override_installation( expected_reconcile_config = ReconcileConfig( data_source="snowflake", report_type="all", - creds=ReconcileCredentialConfig(vault_type="local", source_creds={"test_secret": "dummy"}), + creds=ReconcileCredentialsConfig(vault_type="local", vault_secret_names={"test_secret": "dummy"}), database_config=DatabaseConfig( source_schema="tpch_sf1000", target_catalog="tpch", @@ -1021,7 +1021,7 @@ def test_configure_all_override_installation( { "data_source": "snowflake", "report_type": "all", - "creds": {"vault_type": "local", "source_creds": {"test_secret": "dummy"}}, + "creds": {"vault_type": "local", "vault_secret_names": {"test_secret": "dummy"}}, "database_config": { "source_catalog": "snowflake_sample_data", "source_schema": "tpch_sf1000", From a1729ec4d6a547b2a63d5fc5167e5a6f6cebf5ba Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Mon, 15 Dec 2025 12:27:09 +0100 Subject: [PATCH 14/17] update notebook --- .../lakebridge_recon_main.html | 17 ++++++++--------- .../static/lakebridge_reconciliation.dbc | Bin 19201 -> 19374 bytes 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/lakebridge/static/lakebridge_reconcile/lakebridge_recon_main.html b/docs/lakebridge/static/lakebridge_reconcile/lakebridge_recon_main.html index 3f4ab49382..6ab8a6eeb0 100644 --- a/docs/lakebridge/static/lakebridge_reconcile/lakebridge_recon_main.html +++ b/docs/lakebridge/static/lakebridge_reconcile/lakebridge_recon_main.html @@ -1,8 +1,7 @@ - - + lakebridge_recon_main - Databricks @@ -11,16 +10,16 @@ - - - - + + + + diff --git a/docs/lakebridge/static/lakebridge_reconciliation.dbc b/docs/lakebridge/static/lakebridge_reconciliation.dbc index a320ebc2b3f8e7e74f04789719b701e16f3b7523..5547db76887e827323f1965413135a8d6e1251d9 100644 GIT binary patch literal 19374 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 literal 19201 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 From a51fa94e3f5833bd7204c70509b0c5af673b5d58 Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Wed, 24 Dec 2025 14:47:28 +0100 Subject: [PATCH 15/17] fix tests and minor refactor --- .../lakebridge/helpers/recon_config_utils.py | 9 ++++++-- src/databricks/labs/lakebridge/install.py | 4 +--- tests/unit/test_install.py | 21 ++++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py index 236d9fb16e..90b5ccfe8b 100644 --- a/src/databricks/labs/lakebridge/helpers/recon_config_utils.py +++ b/src/databricks/labs/lakebridge/helpers/recon_config_utils.py @@ -1,6 +1,8 @@ import logging from databricks.labs.blueprint.tui import Prompts + +from databricks.labs.lakebridge.reconcile.connectors.credentials import ReconcileCredentialsConfig from databricks.labs.lakebridge.reconcile.constants import ReconSourceType from databricks.sdk import WorkspaceClient @@ -110,7 +112,10 @@ def _connection_details(self, source: str): case ReconSourceType.MSSQL.value | ReconSourceType.SYNAPSE.value: return self._prompt_mssql_connection_details() - def prompt_recon_creds(self, source: str) -> tuple[str, dict[str, str]]: + def prompt_recon_creds(self, source: str) -> ReconcileCredentialsConfig: logger.info("Please provide secret names in the following steps in the format /") connection_details = self._connection_details(source) - return "databricks", connection_details + return ReconcileCredentialsConfig( + vault_type="databricks", + vault_secret_names=connection_details, + ) diff --git a/src/databricks/labs/lakebridge/install.py b/src/databricks/labs/lakebridge/install.py index d5a72b875b..cf5e73b5ce 100644 --- a/src/databricks/labs/lakebridge/install.py +++ b/src/databricks/labs/lakebridge/install.py @@ -25,7 +25,6 @@ from databricks.labs.lakebridge.deployment.configurator import ResourceConfigurator from databricks.labs.lakebridge.deployment.installation import WorkspaceInstallation from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts -from databricks.labs.lakebridge.reconcile.connectors.credentials import ReconcileCredentialsConfig from databricks.labs.lakebridge.reconcile.constants import ReconReportType, ReconSourceType from databricks.labs.lakebridge.transpiler.installers import ( BladebridgeInstaller, @@ -330,8 +329,7 @@ def _prompt_for_new_reconcile_installation(self) -> ReconcileConfig: "Select the report type:", [report_type.value for report_type in ReconReportType] ) if data_source != ReconSourceType.DATABRICKS.value: - vault, credentials = self._recon_creds_prompts.prompt_recon_creds(data_source) - creds = ReconcileCredentialsConfig(vault, credentials) + creds = self._recon_creds_prompts.prompt_recon_creds(data_source) else: creds = None diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index b7a2e4f1c6..57eaab1068 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -22,7 +22,7 @@ from databricks.labs.lakebridge.deployment.installation import WorkspaceInstallation from databricks.labs.lakebridge.helpers.recon_config_utils import ReconConfigPrompts from databricks.labs.lakebridge.install import WorkspaceInstaller -from databricks.labs.lakebridge.reconcile.connectors.credentials import ReconcileCredentialsConfig +from databricks.labs.lakebridge.reconcile.connectors.credentials import build_recon_creds from databricks.labs.lakebridge.reconcile.constants import ReconSourceType, ReconReportType from databricks.labs.lakebridge.transpiler.installers import ( TranspilerInstaller, @@ -596,6 +596,7 @@ def test_configure_reconcile_installation_no_override(ws: WorkspaceClient, recon @pytest.mark.parametrize("datasource", ["oracle"]) def test_configure_reconcile_installation_config_error_continue_install( datasource: str, + secret_scope: str, ws: WorkspaceClient, reconcile_config: ReconcileConfig, reconcile_config_v2_yml: dict, @@ -635,8 +636,7 @@ def test_configure_reconcile_installation_config_error_continue_install( ) creds_mock = MagicMock(ReconConfigPrompts) - creds_sample = ReconcileCredentialsConfig("databricks", {"test_secret": "dummy"}) - creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.vault_secret_names) + creds_mock.prompt_recon_creds.return_value = build_recon_creds(datasource, secret_scope) workspace_installer = WorkspaceInstaller( ctx.workspace_client, ctx.prompts, @@ -664,7 +664,12 @@ def test_configure_reconcile_installation_config_error_continue_install( @pytest.mark.parametrize("datasource", ["snowflake", "databricks"]) @patch("webbrowser.open") def test_configure_reconcile_no_existing_installation( - _, datasource: str, ws: WorkspaceClient, reconcile_config: ReconcileConfig, reconcile_config_v2_yml: dict + _, + datasource: str, + secret_scope: str, + ws: WorkspaceClient, + reconcile_config: ReconcileConfig, + reconcile_config_v2_yml: dict, ) -> None: prompts = MockPrompts( { @@ -692,8 +697,7 @@ def test_configure_reconcile_no_existing_installation( ) creds_mock = MagicMock(ReconConfigPrompts) - creds_sample = ReconcileCredentialsConfig("databricks", {"test_secret": "dummy"}) - creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.vault_secret_names) + creds_mock.prompt_recon_creds.return_value = build_recon_creds(datasource, secret_scope) workspace_installer = WorkspaceInstaller( ctx.workspace_client, ctx.prompts, @@ -716,6 +720,8 @@ def test_configure_reconcile_no_existing_installation( @pytest.mark.parametrize("datasource", ["snowflake"]) def test_configure_all_override_installation( + datasource: str, + secret_scope: str, ws_installer: Callable[..., WorkspaceInstaller], ws: WorkspaceClient, reconcile_config: ReconcileConfig, @@ -773,8 +779,7 @@ def test_configure_all_override_installation( ) creds_mock = MagicMock(ReconConfigPrompts) - creds_sample = ReconcileCredentialsConfig("databricks", {"test_secret": "dummy"}) - creds_mock.prompt_recon_creds.return_value = (creds_sample.vault_type, creds_sample.vault_secret_names) + creds_mock.prompt_recon_creds.return_value = build_recon_creds(datasource, secret_scope) workspace_installer = ws_installer( ctx.workspace_client, ctx.prompts, From 1e9785b9cd85f0bd9127b625e6f4a6dc8ae179de Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Wed, 24 Dec 2025 15:08:24 +0100 Subject: [PATCH 16/17] fix one more test --- tests/unit/test_install.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index 57eaab1068..822aafc7d1 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -606,7 +606,6 @@ def test_configure_reconcile_installation_config_error_continue_install( { r"Select the Data Source": str(RECONCILE_DATA_SOURCES.index(datasource)), r"Select the report type": str(RECONCILE_REPORT_TYPES.index("all")), - r"Enter Secret scope name to store .* connection details / secrets": f"remorph_{datasource}", r"Enter source database name for .*": "tpch_sf1000", r"Enter target catalog name for Databricks": "tpch", r"Enter target schema name for Databricks": "1000gb", @@ -675,7 +674,7 @@ def test_configure_reconcile_no_existing_installation( { r"Select the Data Source": str(RECONCILE_DATA_SOURCES.index(datasource)), r"Select the report type": str(RECONCILE_REPORT_TYPES.index("all")), - r"Enter source catalog name for .*": "snowflake_sample_data", + r"Enter source catalog name for .*": f"{datasource}_sample_data", r"Enter source schema name for .*": "tpch_sf1000", r"Enter target catalog name for Databricks": "tpch", r"Enter target schema name for Databricks": "1000gb", @@ -740,7 +739,7 @@ def test_configure_all_override_installation( r"Open .* in the browser?": "no", r"Select the Data Source": str(RECONCILE_DATA_SOURCES.index("snowflake")), r"Select the report type": str(RECONCILE_REPORT_TYPES.index("all")), - r"Enter source catalog name for .*": "snowflake_sample_data", + r"Enter source catalog name for .*": f"{datasource}_sample_data", r"Enter source schema name for .*": "tpch_sf1000", r"Enter target catalog name for Databricks": "tpch", r"Enter target schema name for Databricks": "1000gb", From 44ea01542ba6cb4ea8d4e5ed6da80850c94ed93f Mon Sep 17 00:00:00 2001 From: M Abulazm Date: Thu, 25 Dec 2025 15:32:34 +0100 Subject: [PATCH 17/17] fix type of creds property in docs --- docs/lakebridge/docs/reconcile/recon_notebook.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/lakebridge/docs/reconcile/recon_notebook.mdx b/docs/lakebridge/docs/reconcile/recon_notebook.mdx index 645fd2ae0d..13526f4dc5 100644 --- a/docs/lakebridge/docs/reconcile/recon_notebook.mdx +++ b/docs/lakebridge/docs/reconcile/recon_notebook.mdx @@ -69,9 +69,9 @@ We use the class `ReconcileConfig` to configure the properties required for reco class ReconcileConfig: data_source: str report_type: str - creds: ReconcileCredentialsConfig | str | None = None database_config: DatabaseConfig metadata_config: ReconcileMetadataConfig + creds: ReconcileCredentialsConfig | None = None ``` Parameters: