Skip to content

Commit d855c91

Browse files
feat(import assets): Pass DB passwords in the import command (#340)
* feat(import assets): Pass DB passwords in the import command * Removing unused skip * Implementing PR feedback Co-authored-by: Beto Dealmeida <[email protected]>
1 parent f9ade14 commit d855c91

File tree

2 files changed

+216
-19
lines changed

2 files changed

+216
-19
lines changed

src/preset_cli/cli/superset/sync/native/command.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ def render_yaml(path: Path, env: Dict[str, Any]) -> Dict[str, Any]:
226226
"This way other asset types included get created but not overwritten."
227227
),
228228
)
229+
@click.option(
230+
"--db-password",
231+
multiple=True,
232+
help="Password for DB connections being imported (eg, uuid1=my_db_password)",
233+
)
229234
@click.pass_context
230235
def native( # pylint: disable=too-many-locals, too-many-arguments, too-many-branches
231236
ctx: click.core.Context,
@@ -239,6 +244,7 @@ def native( # pylint: disable=too-many-locals, too-many-arguments, too-many-bra
239244
load_env: bool = False,
240245
split: bool = False,
241246
continue_on_error: bool = False,
247+
db_password: Tuple[str, ...] | None = None,
242248
) -> None:
243249
"""
244250
Sync exported DBs/datasets/charts/dashboards to Superset.
@@ -272,6 +278,8 @@ def native( # pylint: disable=too-many-locals, too-many-arguments, too-many-bra
272278
if load_env:
273279
env["env"] = os.environ # type: ignore
274280

281+
pwds = dict(kv.split("=", 1) for kv in db_password or [])
282+
275283
# read all the YAML files
276284
configs: Dict[Path, AssetConfig] = {}
277285
queue = [root]
@@ -306,7 +314,7 @@ def native( # pylint: disable=too-many-locals, too-many-arguments, too-many-bra
306314
relative_path.parts[0] == "databases"
307315
and config["uuid"] not in existing_databases
308316
):
309-
prompt_for_passwords(relative_path, config)
317+
add_password_to_config(relative_path, config, pwds)
310318
verify_db_connectivity(config)
311319
if relative_path.parts[0] == "datasets" and isinstance(
312320
config.get("params"),
@@ -454,15 +462,23 @@ def verify_db_connectivity(config: Dict[str, Any]) -> None:
454462
_logger.debug(ex)
455463

456464

457-
def prompt_for_passwords(path: Path, config: Dict[str, Any]) -> None:
465+
def add_password_to_config(
466+
path: Path,
467+
config: Dict[str, Any],
468+
pwds: Dict[str, Any],
469+
) -> None:
458470
"""
459-
Prompt user for masked passwords.
471+
Add password passed in the command to the config.
460472
461-
Modify the config in place.
473+
Prompt user for masked passwords for new connections if not provided. Modify
474+
the config in place.
462475
"""
463476
uri = config["sqlalchemy_uri"]
464477
password = make_url(uri).password
465-
if password == PASSWORD_MASK and config.get("password") is None:
478+
479+
if config["uuid"] in pwds:
480+
config["password"] = pwds[config["uuid"]]
481+
elif password == PASSWORD_MASK and config.get("password") is None:
466482
config["password"] = getpass.getpass(
467483
f"Please provide the password for {path}: ",
468484
)

tests/cli/superset/sync/native/command_test.py

Lines changed: 195 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,33 +23,46 @@
2323
from preset_cli.cli.superset.main import superset_cli
2424
from preset_cli.cli.superset.sync.native.command import (
2525
ResourceType,
26+
add_password_to_config,
2627
import_resources,
2728
import_resources_individually,
2829
load_user_modules,
29-
prompt_for_passwords,
3030
raise_helper,
3131
verify_db_connectivity,
3232
)
3333
from preset_cli.exceptions import ErrorLevel, ErrorPayload, SupersetError
3434

3535

36-
def test_prompt_for_passwords(mocker: MockerFixture) -> None:
36+
def test_add_password_to_config(mocker: MockerFixture) -> None:
3737
"""
38-
Test ``prompt_for_passwords``.
38+
Test ``add_password_to_config``.
3939
"""
4040
getpass = mocker.patch("preset_cli.cli.superset.sync.native.command.getpass")
4141

42-
config = {"sqlalchemy_uri": "postgresql://user:XXXXXXXXXX@host:5432/db"}
42+
config = {
43+
"sqlalchemy_uri": "postgresql://user:XXXXXXXXXX@host:5432/db",
44+
"uuid": "uuid1",
45+
}
4346
path = Path("/path/to/root/databases/psql.yaml")
44-
prompt_for_passwords(path, config)
47+
add_password_to_config(path, config, {})
4548

4649
getpass.getpass.assert_called_with(f"Please provide the password for {path}: ")
4750

4851
config["password"] = "password123"
4952
getpass.reset_mock()
50-
prompt_for_passwords(path, config)
53+
add_password_to_config(path, config, {})
5154
getpass.getpass.assert_not_called()
5255

56+
getpass.reset_mock()
57+
add_password_to_config(path, config, {"uuid1": "password321"})
58+
getpass.getpass.assert_not_called()
59+
# db_password takes precedence over config["password"]
60+
assert config == {
61+
"sqlalchemy_uri": "postgresql://user:XXXXXXXXXX@host:5432/db",
62+
"uuid": "uuid1",
63+
"password": "password321",
64+
}
65+
5366

5467
def test_import_resources(mocker: MockerFixture) -> None:
5568
"""
@@ -418,8 +431,8 @@ def test_native_password_prompt(mocker: MockerFixture, fs: FakeFilesystem) -> No
418431
client.get_databases.return_value = []
419432
mocker.patch("preset_cli.cli.superset.sync.native.command.import_resources")
420433
mocker.patch("preset_cli.cli.superset.main.UsernamePasswordAuth")
421-
prompt_for_passwords = mocker.patch(
422-
"preset_cli.cli.superset.sync.native.command.prompt_for_passwords",
434+
add_password_to_config = mocker.patch(
435+
"preset_cli.cli.superset.sync.native.command.add_password_to_config",
423436
)
424437

425438
runner = CliRunner()
@@ -430,9 +443,9 @@ def test_native_password_prompt(mocker: MockerFixture, fs: FakeFilesystem) -> No
430443
catch_exceptions=False,
431444
)
432445
assert result.exit_code == 0
433-
prompt_for_passwords.assert_called()
446+
add_password_to_config.assert_called()
434447

435-
prompt_for_passwords.reset_mock()
448+
add_password_to_config.reset_mock()
436449
client.get_databases.return_value = [
437450
{"uuid": "uuid1"},
438451
]
@@ -442,7 +455,7 @@ def test_native_password_prompt(mocker: MockerFixture, fs: FakeFilesystem) -> No
442455
catch_exceptions=False,
443456
)
444457
assert result.exit_code == 0
445-
prompt_for_passwords.assert_not_called()
458+
add_password_to_config.assert_not_called()
446459
client.get_uuids.assert_not_called()
447460

448461

@@ -610,8 +623,8 @@ def test_native_legacy_instance(mocker: MockerFixture, fs: FakeFilesystem) -> No
610623
client.get_uuids.return_value = {1: "uuid1"}
611624
mocker.patch("preset_cli.cli.superset.sync.native.command.import_resources")
612625
mocker.patch("preset_cli.cli.superset.main.UsernamePasswordAuth")
613-
prompt_for_passwords = mocker.patch(
614-
"preset_cli.cli.superset.sync.native.command.prompt_for_passwords",
626+
add_password_to_config = mocker.patch(
627+
"preset_cli.cli.superset.sync.native.command.add_password_to_config",
615628
)
616629

617630
runner = CliRunner()
@@ -622,7 +635,7 @@ def test_native_legacy_instance(mocker: MockerFixture, fs: FakeFilesystem) -> No
622635
catch_exceptions=False,
623636
)
624637
assert result.exit_code == 0
625-
prompt_for_passwords.assert_not_called()
638+
add_password_to_config.assert_not_called()
626639

627640

628641
def test_load_user_modules(mocker: MockerFixture, fs: FakeFilesystem) -> None:
@@ -2001,3 +2014,171 @@ def test_native_invalid_asset_type(mocker: MockerFixture, fs: FakeFilesystem) ->
20012014

20022015
assert result.exit_code == 2
20032016
assert "Invalid value for '--asset-type'" in result.output
2017+
2018+
2019+
def test_native_with_db_passwords(mocker: MockerFixture, fs: FakeFilesystem) -> None:
2020+
"""
2021+
Test the ``native`` command while passing db passwords in the command.
2022+
"""
2023+
root = Path("/path/to/root")
2024+
fs.create_dir(root)
2025+
sqlalchemy_uri = {
2026+
"sqlalchemy_uri": "postgresql://user:XXXXXXXXXX@host:5432/db",
2027+
}
2028+
unmasked_uri = {
2029+
"sqlalchemy_uri": "postgresql://user:unmasked@host:5432/db",
2030+
}
2031+
2032+
db_configs = {
2033+
"db_config_masked_no_password": {
2034+
"uuid": "uuid1",
2035+
**sqlalchemy_uri,
2036+
},
2037+
"other_db_config_masked_no_password": {
2038+
"uuid": "uuid2",
2039+
**sqlalchemy_uri,
2040+
},
2041+
"db_config_masked_with_password": {
2042+
"uuid": "uuid3",
2043+
"password": "directpwd!",
2044+
**sqlalchemy_uri,
2045+
},
2046+
"other_db_config_masked_with_password": {
2047+
"uuid": "uuid4",
2048+
"password": "directpwd!again",
2049+
**sqlalchemy_uri,
2050+
},
2051+
"db_config_unmasked": {
2052+
"uuid": "uuid5",
2053+
**unmasked_uri,
2054+
},
2055+
"other_db_config_unmasked": {
2056+
"uuid": "uuid6",
2057+
**unmasked_uri,
2058+
},
2059+
"db_config_unmasked_with_password": {
2060+
"uuid": "uuid7",
2061+
"password": "unmaskedpwd!",
2062+
**unmasked_uri,
2063+
},
2064+
"final_db_config_unmasked_with_password": {
2065+
"uuid": "uuid8",
2066+
"password": "unmaskedpwd!again",
2067+
**unmasked_uri,
2068+
},
2069+
}
2070+
2071+
for file_name, content in db_configs.items():
2072+
fs.create_file(
2073+
root / f"databases/{file_name}.yaml",
2074+
contents=yaml.dump(content),
2075+
)
2076+
2077+
SupersetClient = mocker.patch(
2078+
"preset_cli.cli.superset.sync.native.command.SupersetClient",
2079+
)
2080+
client = SupersetClient()
2081+
client.get_databases.return_value = []
2082+
import_resources = mocker.patch(
2083+
"preset_cli.cli.superset.sync.native.command.import_resources",
2084+
)
2085+
mocker.patch("preset_cli.cli.superset.main.UsernamePasswordAuth")
2086+
mocker.patch("preset_cli.cli.superset.sync.native.command.verify_db_connectivity")
2087+
getpass = mocker.patch("preset_cli.cli.superset.sync.native.command.getpass")
2088+
getpass.getpass.return_value = "pwd_from_prompt"
2089+
2090+
runner = CliRunner()
2091+
result = runner.invoke(
2092+
superset_cli,
2093+
[
2094+
"https://superset.example.org/",
2095+
"sync",
2096+
"native",
2097+
str(root),
2098+
"--db-password",
2099+
"uuid1=pwd_from_command=1",
2100+
"--db-password",
2101+
"uuid3=pwd_from_command=2",
2102+
"--db-password",
2103+
"uuid5=pwd_from_command=3",
2104+
"--db-password",
2105+
"uuid7=pwd_from_command=4",
2106+
],
2107+
catch_exceptions=False,
2108+
)
2109+
assert result.exit_code == 0
2110+
contents = {
2111+
"bundle/databases/db_config_masked_no_password.yaml": yaml.dump(
2112+
{
2113+
"uuid": "uuid1",
2114+
**sqlalchemy_uri,
2115+
"password": "pwd_from_command=1",
2116+
"is_managed_externally": False,
2117+
},
2118+
),
2119+
"bundle/databases/other_db_config_masked_no_password.yaml": yaml.dump(
2120+
{
2121+
"uuid": "uuid2",
2122+
**sqlalchemy_uri,
2123+
"password": "pwd_from_prompt",
2124+
"is_managed_externally": False,
2125+
},
2126+
),
2127+
"bundle/databases/db_config_masked_with_password.yaml": yaml.dump(
2128+
{
2129+
"uuid": "uuid3",
2130+
**sqlalchemy_uri,
2131+
"password": "pwd_from_command=2",
2132+
"is_managed_externally": False,
2133+
},
2134+
),
2135+
"bundle/databases/other_db_config_masked_with_password.yaml": yaml.dump(
2136+
{
2137+
"uuid": "uuid4",
2138+
**sqlalchemy_uri,
2139+
"password": "directpwd!again",
2140+
"is_managed_externally": False,
2141+
},
2142+
),
2143+
"bundle/databases/db_config_unmasked.yaml": yaml.dump(
2144+
{
2145+
"uuid": "uuid5",
2146+
**unmasked_uri,
2147+
"password": "pwd_from_command=3",
2148+
"is_managed_externally": False,
2149+
},
2150+
),
2151+
"bundle/databases/other_db_config_unmasked.yaml": yaml.dump(
2152+
{
2153+
"uuid": "uuid6",
2154+
**unmasked_uri,
2155+
"is_managed_externally": False,
2156+
},
2157+
),
2158+
"bundle/databases/db_config_unmasked_with_password.yaml": yaml.dump(
2159+
{
2160+
"uuid": "uuid7",
2161+
**unmasked_uri,
2162+
"password": "pwd_from_command=4",
2163+
"is_managed_externally": False,
2164+
},
2165+
),
2166+
"bundle/databases/final_db_config_unmasked_with_password.yaml": yaml.dump(
2167+
{
2168+
"uuid": "uuid8",
2169+
**unmasked_uri,
2170+
"password": "unmaskedpwd!again",
2171+
"is_managed_externally": False,
2172+
},
2173+
),
2174+
}
2175+
2176+
import_resources.assert_called_once_with(
2177+
contents,
2178+
client,
2179+
False,
2180+
ResourceType.ASSET,
2181+
)
2182+
getpass.getpass.assert_called_once_with(
2183+
"Please provide the password for databases/other_db_config_masked_no_password.yaml: ",
2184+
)

0 commit comments

Comments
 (0)