Repository: peopledoc/vault-cli

Issue:
Add details on where the settings are coming from 
write in stderr the location of the settings file, if used.

(ping @tlehoux )

Discussion:
ewjoachim: The failing test in travis: it's because there's no .vault.yml. Avoid the falilure by simply defining explicit parameters in the dump-config call (and you won't need DEFAULT anymore, that's even better)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24f56b7..8eea173 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,12 @@
 CHANGELOG
 =========
 
+0.4.0 (unreleased)
+------------------
+
+- Added vault dump-config
+- Added vault bootstrap-env
+
 0.3.9
 -----
 
diff --git a/README.md b/README.md
index cca31b1..ac7eefd 100644
--- a/README.md
+++ b/README.md
@@ -43,19 +43,20 @@ Options:
                                a "password" key.
   -b, --base-path TEXT         Base path for requests
   --backend TEXT               Name of the backend to use (requests, hvac)
+  -v, --verbose                Use multiple times to increase verbosity
   --config-file PATH           Config file to use. Use 'no' to disable config
                                file. Default value: first of ./.vault.yml,
                                ~/.vault.yml, /etc/vault.yml
   -h, --help                   Show this message and exit.
 
 Commands:
-  env      Launches the given command with all secrets from --path...
-  delete   Deletes a single secret.
-  get      Return a single secret value.
-  get-all  Return multiple secrets.
-  list     List all the secrets at the given path.
-  set      Set a single secret to the given value(s).
-
+  delete       Deletes a single secret.
+  dump-config  Displays all the current settings in the format of a config...
+  env          Launches the given command with all secrets from --path...
+  get          Return a single secret value.
+  get-all      Return multiple secrets.
+  list         List all the secrets at the given path.
+  set          Set a single secret to the given value(s).
 ```
 
 ## Authentication
@@ -121,6 +122,9 @@ $ vault bootstrap-env --path blob_secret -- env
 ...
 code=supercode
 ...
+
+# Recreate a configuration file based on the current settings
+$ vault --url https://something --token mytoken dump-config > .vault.yaml
 ```
 
 ## Configuration
diff --git a/tox.ini b/tox.ini
index 61e86d3..d015feb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -14,6 +14,7 @@ commands =
 
 [testenv:check-lint]
 extras =
+    test
     lint
     hvac
 ignore_errors=true
@@ -28,6 +29,7 @@ commands =
 extras =
     dev
     hvac
+    test
 commands =
     isort -y
     black .
diff --git a/vault_cli/cli.py b/vault_cli/cli.py
index 5d5a944..915aea8 100644
--- a/vault_cli/cli.py
+++ b/vault_cli/cli.py
@@ -16,6 +16,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 """
+import logging
 import os
 from typing import Any, Dict, Mapping, NoReturn, Sequence
 
@@ -24,6 +25,8 @@ import yaml
 
 from vault_cli import client, settings, types
 
+logger = logging.getLogger(__name__)
+
 CONTEXT_SETTINGS = {
     "help_option_names": ["-h", "--help"],
     "auto_envvar_prefix": settings.ENV_PREFIX,
@@ -44,6 +47,13 @@ def load_config(ctx: click.Context, param: click.Parameter, value: str) -> None:
     ctx.default_map = config
 
 
+def set_verbosity(ctx: click.Context, param: click.Parameter, value: int) -> int:
+    level = settings.get_log_level(verbosity=value)
+    logging.basicConfig(level=level)
+    logger.info(f"Log level set to {logging.getLevelName(level)}")
+    return value
+
+
 @click.group(context_settings=CONTEXT_SETTINGS)
 @click.pass_context
 @click.option(
@@ -88,6 +98,14 @@ def load_config(ctx: click.Context, param: click.Parameter, value: str) -> None:
     default=settings.DEFAULTS["backend"],
     help="Name of the backend to use (requests, hvac)",
 )
+@click.option(
+    "-v",
+    "--verbose",
+    is_eager=True,
+    callback=set_verbosity,
+    count=True,
+    help="Use multiple times to increase verbosity",
+)
 @click.option(
     "--config-file",
     is_eager=True,
@@ -105,14 +123,18 @@ def cli(ctx: click.Context, **kwargs) -> None:
 
     """
     kwargs.pop("config_file")
+    verbose = kwargs.pop("verbose")
     backend: str = kwargs.pop("backend")
 
     kwargs.update(extract_special_args(ctx.default_map, os.environ))
 
     # There might still be files to read, so let's do it now
     kwargs = settings.read_all_files(kwargs)
+    saved_settings = kwargs.copy()
+    saved_settings.update({"backend": backend, "verbose": verbose})
     try:
         ctx.obj = client.get_client_from_kwargs(backend=backend, **kwargs)
+        ctx.obj.saved_settings = saved_settings
     except ValueError as exc:
         raise click.UsageError(str(exc))
 
@@ -279,6 +301,21 @@ def exec_command(command: Sequence[str], environ: Dict[str, str]) -> NoReturn:
     os.execvpe(command[0], tuple(command), environ)
 
 
+@cli.command("dump-config")
+@click.pass_obj
+def dump_config(client_obj: client.VaultClientBase,) -> None:
+    """
+    Displays all the current settings in the format of a config file.
+    """
+    assert client_obj.saved_settings
+    click.echo(
+        yaml.safe_dump(
+            client_obj.saved_settings, default_flow_style=False, explicit_start=True
+        ),
+        nl=False,
+    )
+
+
 def main():
     # https://click.palletsprojects.com/en/7.x/python3/
     os.environ.setdefault("LC_ALL", "C.UTF-8")
diff --git a/vault_cli/client.py b/vault_cli/client.py
index d1f43d4..d526c91 100644
--- a/vault_cli/client.py
+++ b/vault_cli/client.py
@@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 """
 import json
-from typing import Iterable, Type, Union
+from typing import Iterable, Optional, Type, Union
 
 from vault_cli import settings, types
 
@@ -111,6 +111,9 @@ class VaultAPIException(Exception):
 
 
 class VaultClientBase:
+
+    saved_settings: Optional[types.SettingsDict] = None
+
     def __init__(
         self,
         url: str,
diff --git a/vault_cli/settings.py b/vault_cli/settings.py
index 8f465aa..a459a4f 100644
--- a/vault_cli/settings.py
+++ b/vault_cli/settings.py
@@ -17,6 +17,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 """
 
+import logging
 import os
 import sys
 from functools import lru_cache
@@ -26,6 +27,8 @@ import yaml
 
 from vault_cli import types
 
+logger = logging.getLogger(__name__)
+
 ENV_PREFIX = "VAULT_CLI"
 
 # Ordered by increasing priority
@@ -47,9 +50,21 @@ DEFAULTS = {
 def read_config_file(file_path: str) -> Optional[types.SettingsDict]:
     try:
         with open(os.path.expanduser(file_path), "r") as f:
-            return yaml.safe_load(f)
+            config = yaml.safe_load(f)
+            logger.info(
+                f"Reading yaml config file at {file_path}, "
+                f"contains keys: {', '.join(config)}"
+            )
+            return config
+    except FileNotFoundError:
+        logger.debug(f"No config file at {file_path} (skipping)")
     except IOError:
-        return None
+        logger.warning(
+            f"Config file exists at {file_path}, but cannot be read. "
+            "Have you checked permissions? (skipping)"
+        )
+
+    return None
 
 
 def dash_to_underscores(config: types.SettingsDict) -> types.SettingsDict:
@@ -98,36 +113,36 @@ def build_config_from_env(environ: Dict[str, str]) -> types.SettingsDict:
 
 def read_all_files(config: types.SettingsDict) -> types.SettingsDict:
     config = config.copy()
-    # Files override direct values when both are defined
-    certificate_file = config.pop("certificate_file", None)
-    if certificate_file:
-        assert isinstance(certificate_file, str)
-        config["certificate"] = read_file(certificate_file)
-
-    password_file = config.pop("password_file", None)
-    if password_file:
-        assert isinstance(password_file, str)
-        config["password"] = read_file(password_file)
 
-    token_file = config.pop("token_file", None)
-    if token_file:
-        assert isinstance(token_file, str)
-        config["token"] = read_file(token_file)
+    replace_path_with_content(config=config, setting_name="certificate")
+    replace_path_with_content(config=config, setting_name="password")
+    replace_path_with_content(config=config, setting_name="token")
 
     return config
 
 
+def replace_path_with_content(config: types.SettingsDict, setting_name: str) -> None:
+    """
+    Modifies config dict in place: reads the file at <setting_name>_file
+    and put its contents at <setting_name> instead.
+    """
+    # Files override direct values when both are defined
+    filename = config.pop(f"{setting_name}_file", None)
+    if filename:
+        assert isinstance(filename, str)
+        logger.info(f"Reading value of '{setting_name}' from file {filename}")
+        config[setting_name] = read_file(filename)
+
+
 def read_file(path: str) -> Optional[str]:
     """
     Returns the content of the pointed file
     """
-    if not path:
-        return None
-
     if path == "-":
         return sys.stdin.read().strip()
 
     with open(os.path.expanduser(path)) as file_handler:
+
         return file_handler.read().strip()
 
 
@@ -152,3 +167,13 @@ def get_vault_options(**kwargs: types.Settings):
     values.update(kwargs)
 
     return values
+
+
+def get_log_level(verbosity: int) -> int:
+    """
+    Given the number of repetitions of the flag -v,
+    returns the desired log level
+    """
+    return {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}.get(
+        min((2, verbosity)), 0
+    )


Test patch:
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py
index 0da3aea..026d67a 100644
--- a/tests/integration/test_integration.py
+++ b/tests/integration/test_integration.py
@@ -2,6 +2,7 @@ import os
 import subprocess
 
 import pytest
+
 import vault_cli
 from vault_cli import cli
 
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index adcac72..cc2c965 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -1,7 +1,9 @@
-import yaml
+import logging
 
 import pytest
-from vault_cli import cli, client
+import yaml
+
+from vault_cli import cli, client, settings
 
 
 class FakeClient(client.VaultClientBase):
@@ -277,3 +279,28 @@ def test_extract_special_args(config, environ, expected):
     result = {key: value for key, value in result.items() if value is not None}
 
     assert result == expected
+
+
+def test_set_verbosity(mocker):
+    basic_config = mocker.patch("logging.basicConfig")
+
+    cli.set_verbosity(None, None, 1)
+
+    basic_config.assert_called_with(level=logging.INFO)
+
+
+def test_dump_config(cli_runner, backend):
+    result = cli_runner.invoke(
+        cli.cli,
+        ["--base-path", "mybase/", "--token-file", "-", "dump-config"],
+        input="some-token",
+    )
+
+    expected_settings = settings.DEFAULTS.copy()
+    expected_settings.update(
+        {"base_path": "mybase/", "token": "some-token", "verbose": 0}
+    )
+
+    output = yaml.safe_load(result.output)
+
+    assert output == expected_settings
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index 22c27ee..2901b70 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -1,4 +1,5 @@
 import pytest
+
 from vault_cli import client
 
 
diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py
index 6393aa3..d954503 100644
--- a/tests/unit/test_clients.py
+++ b/tests/unit/test_clients.py
@@ -1,4 +1,5 @@
 import pytest
+
 from vault_cli import client
 
 
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
index 512da21..0f6c6a5 100644
--- a/tests/unit/test_settings.py
+++ b/tests/unit/test_settings.py
@@ -1,8 +1,10 @@
 from __future__ import unicode_literals
 
 import io
+import logging
 
 import pytest
+
 from vault_cli import settings
 
 
@@ -10,6 +12,10 @@ def test_read_config_file_not_existing():
     assert settings.read_config_file("/non-existant-file") is None
 
 
+def test_read_config_file_other_error():
+    assert settings.read_config_file("/") is None
+
+
 def test_read_config_file(tmpdir):
     path = str(tmpdir.join("test.yml"))
     open(path, "w").write('{"yay": 1}')
@@ -45,10 +51,6 @@ def test_read_all_files(tmpdir):
     assert settings.read_all_files(d) == expected
 
 
-def test_read_file_no_path():
-    assert settings.read_file(None) is None
-
-
 def test_read_file_stdin(mocker):
     mocker.patch("sys.stdin", io.StringIO("yay"))
     assert settings.read_file("-") == "yay"
@@ -136,3 +138,11 @@ def test_load_bool_wrong():
 )
 def test_build_config_from_env(value, expected):
     assert settings.build_config_from_env(value) == expected
+
+
+@pytest.mark.parametrize(
+    "verbosity, log_level",
+    [(0, "WARNING"), (1, "INFO"), (2, "DEBUG"), (3, "DEBUG"), (None, "WARNING")],
+)
+def get_log_level(verbosity, log_level):
+    assert settings.get_log_level(verbosity=verbosity) == getattr(logging, log_level)