Skip to content

Commit 8c87cf9

Browse files
Merge pull request #1007 from GitGuardian/jgriffe/improve-api-status-output-info
feat(api-status): add api key and instance sources to the api-status command output
2 parents 9df9d59 + 9203450 commit 8c87cf9

File tree

8 files changed

+185
-21
lines changed

8 files changed

+185
-21
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Added
2+
3+
- The `api-status` command now returns the sources of both the api-key and instance used.

doc/schemas/api-status.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@
1313
"format": "uri",
1414
"description": "URL of the GitGuardian instance"
1515
},
16+
"instance_source": {
17+
"enum": ["CMD_OPTION", "DOTENV", "ENV_VAR", "USER_CONFIG", "DEFAULT"],
18+
"description": "Source the instance was read from"
19+
},
20+
"api_key_source": {
21+
"enum": ["DOTENV", "ENV_VAR", "USER_CONFIG"],
22+
"description": "Source the API key was read from"
23+
},
1624
"detail": {
1725
"type": "string",
1826
"description": "Human-readable version of the status"

ggshield/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def cli(
107107
if allow_self_signed:
108108
user_config.allow_self_signed = allow_self_signed
109109

110-
load_dot_env()
110+
ctx_obj.config._dotenv_vars = load_dot_env()
111111

112112
_set_color(ctx)
113113

ggshield/cmd/status.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,31 @@
2424
@add_common_options()
2525
@click.pass_context
2626
def status_cmd(ctx: click.Context, **kwargs: Any) -> int:
27-
"""Show API status and version."""
27+
"""Show API status and version, along with API key and instance sources."""
2828
ctx_obj = ContextObj.get(ctx)
2929
client = create_client_from_config(ctx_obj.config)
3030
response: HealthCheckResponse = client.health_check()
3131

3232
if not isinstance(response, HealthCheckResponse):
3333
raise UnexpectedError("Unexpected health check response")
3434

35+
instance, instance_source = ctx_obj.config.get_instance_name_and_source()
36+
_, api_key_source = ctx_obj.config.get_api_key_and_source()
3537
if ctx_obj.use_json:
3638
json_output = response.to_dict()
37-
json_output["instance"] = client.base_uri
39+
json_output["instance"] = instance
40+
json_output["instance_source"] = instance_source.name
41+
json_output["api_key_source"] = api_key_source.name
3842
click.echo(json.dumps(json_output))
3943
else:
4044
click.echo(
41-
f"{format_text('API URL:', STYLE['key'])} {client.base_uri}\n"
45+
f"{format_text('API URL:', STYLE['key'])} {instance}\n"
4246
f"{format_text('Status:', STYLE['key'])} {format_healthcheck_status(response)}\n"
4347
f"{format_text('App version:', STYLE['key'])} {response.app_version or 'Unknown'}\n"
4448
f"{format_text('Secrets engine version:', STYLE['key'])} "
45-
f"{response.secrets_engine_version or 'Unknown'}\n"
49+
f"{response.secrets_engine_version or 'Unknown'}\n\n"
50+
f"{format_text('Instance source:', STYLE['key'])} {instance_source.value}\n"
51+
f"{format_text('API key source:', STYLE['key'])} {api_key_source.value}\n"
4652
)
4753

4854
return 0

ggshield/core/config/config.py

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import logging
22
import os
3+
from enum import Enum
34
from pathlib import Path
4-
from typing import Any, Optional
5+
from typing import Any, Optional, Set, Tuple
56

67
import click
78

@@ -17,6 +18,19 @@
1718
)
1819

1920

21+
class ConfigSource(Enum):
22+
"""
23+
Enum of the different sources of configuration
24+
where an API key or instance URL can come from
25+
"""
26+
27+
CMD_OPTION = "command line option"
28+
DOTENV = ".env file"
29+
ENV_VAR = "environment variable"
30+
USER_CONFIG = "user config"
31+
DEFAULT = "default"
32+
33+
2034
logger = logging.getLogger(__name__)
2135

2236

@@ -26,7 +40,13 @@ class Config:
2640
AuthConfig.
2741
"""
2842

29-
__slots__ = ["user_config", "auth_config", "_cmdline_instance_name", "_config_path"]
43+
__slots__ = [
44+
"user_config",
45+
"auth_config",
46+
"_cmdline_instance_name",
47+
"_config_path",
48+
"_dotenv_vars",
49+
]
3050

3151
user_config: UserConfig
3252
auth_config: AuthConfig
@@ -36,10 +56,16 @@ class Config:
3656

3757
_config_path: Path
3858

59+
# This environment variable helps us knowing whether environment variables
60+
# were set by the dotenv file or not
61+
# It is used in the `api-status` command to return the API key and instance sources
62+
_dotenv_vars: Set[str]
63+
3964
def __init__(self, config_path: Optional[Path] = None):
4065
self.user_config, self._config_path = UserConfig.load(config_path=config_path)
4166
self.auth_config = AuthConfig.load()
4267
self._cmdline_instance_name = None
68+
self._dotenv_vars = set()
4369

4470
def save(self) -> None:
4571
self.user_config.save(self._config_path)
@@ -51,17 +77,23 @@ def config_path(self) -> Path:
5177

5278
@property
5379
def instance_name(self) -> str:
80+
return self.get_instance_name_and_source()[0]
81+
82+
def get_instance_name_and_source(self) -> Tuple[str, ConfigSource]:
5483
"""
84+
Return the instance name and source of the selected instance.
85+
5586
The instance name (defaulting to URL) of the selected instance
5687
priority order is:
5788
- set from the command line (by setting cmdline_instance_name)
58-
- env var (in auth_config.current_instance)
89+
- GITGUARDIAN_INSTANCE env var
90+
- GITGUARDIAN_API_URL env var
5991
- in local user config (in user_config.dashboard_url)
6092
- in global user config (in user_config.dashboard_url)
6193
- the default instance
6294
"""
6395
if self._cmdline_instance_name:
64-
return self._cmdline_instance_name
96+
return self._cmdline_instance_name, ConfigSource.CMD_OPTION
6597

6698
try:
6799
url = os.environ["GITGUARDIAN_INSTANCE"]
@@ -70,20 +102,30 @@ def instance_name(self) -> str:
70102
pass
71103
else:
72104
validate_instance_url(url)
73-
return remove_url_trailing_slash(url)
105+
source = (
106+
ConfigSource.DOTENV
107+
if "GITGUARDIAN_INSTANCE" in self._dotenv_vars
108+
else ConfigSource.ENV_VAR
109+
)
110+
return remove_url_trailing_slash(url), source
74111

75112
try:
76113
name = os.environ["GITGUARDIAN_API_URL"]
77114
logger.debug("Using API URL from $GITGUARDIAN_API_URL")
78115
except KeyError:
79116
pass
80117
else:
81-
return api_to_dashboard_url(name, warn=True)
118+
source = (
119+
ConfigSource.DOTENV
120+
if "GITGUARDIAN_API_URL" in self._dotenv_vars
121+
else ConfigSource.ENV_VAR
122+
)
123+
return api_to_dashboard_url(name, warn=True), source
82124

83125
if self.user_config.instance:
84-
return self.user_config.instance
126+
return self.user_config.instance, ConfigSource.USER_CONFIG
85127

86-
return DEFAULT_INSTANCE_URL
128+
return DEFAULT_INSTANCE_URL, ConfigSource.DEFAULT
87129

88130
@property
89131
def cmdline_instance_name(self) -> Optional[str]:
@@ -123,7 +165,12 @@ def dashboard_url(self) -> str:
123165

124166
@property
125167
def api_key(self) -> str:
168+
return self.get_api_key_and_source()[0]
169+
170+
def get_api_key_and_source(self) -> Tuple[str, ConfigSource]:
126171
"""
172+
Return the selected API key and its source
173+
127174
The API key to use
128175
priority order is
129176
- env var
@@ -132,9 +179,15 @@ def api_key(self) -> str:
132179
try:
133180
key = os.environ["GITGUARDIAN_API_KEY"]
134181
logger.debug("Using API key from $GITGUARDIAN_API_KEY")
182+
source = (
183+
ConfigSource.DOTENV
184+
if "GITGUARDIAN_API_KEY" in self._dotenv_vars
185+
else ConfigSource.ENV_VAR
186+
)
135187
except KeyError:
136188
key = self.auth_config.get_instance_token(self.instance_name)
137-
return key
189+
source = ConfigSource.USER_CONFIG
190+
return key, source
138191

139192
def add_ignored_match(self, *args: Any, **kwargs: Any) -> None:
140193
return self.user_config.secret.add_ignored_match(*args, **kwargs)

ggshield/core/env_utils.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import logging
22
import os
33
from pathlib import Path
4-
from typing import Optional
4+
from typing import Optional, Set
55

6-
from dotenv import load_dotenv
6+
from dotenv import dotenv_values, load_dotenv
77

88
from ggshield.core import ui
99
from ggshield.utils.git_shell import get_git_root, is_git_dir
1010
from ggshield.utils.os import getenv_bool
1111

1212

13+
TRACKED_ENV_VARS = {
14+
"GITGUARDIAN_INSTANCE",
15+
"GITGUARDIAN_API_URL",
16+
"GITGUARDIAN_API_KEY",
17+
}
18+
1319
logger = logging.getLogger(__name__)
1420

1521

@@ -39,15 +45,21 @@ def _find_dot_env() -> Optional[Path]:
3945
return None
4046

4147

42-
def load_dot_env() -> None:
43-
"""Loads .env file into os.environ."""
48+
def load_dot_env() -> Set[str]:
49+
"""
50+
Loads .env file into os.environ.
51+
Return the list of env vars that were set by the dotenv file
52+
among env vars in TRACKED_ENV_VARS
53+
"""
4454
dont_load_env = getenv_bool("GITGUARDIAN_DONT_LOAD_ENV")
4555
if dont_load_env:
4656
logger.debug("Not loading .env, GITGUARDIAN_DONT_LOAD_ENV is set")
47-
return
57+
return set()
4858

4959
dot_env_path = _find_dot_env()
5060
if dot_env_path:
5161
dot_env_path = dot_env_path.absolute()
5262
logger.debug("Loading environment file %s", dot_env_path)
5363
load_dotenv(dot_env_path, override=True)
64+
65+
return dotenv_values(dot_env_path).keys() & TRACKED_ENV_VARS

tests/unit/cmd/test_status.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33

44
import jsonschema
55
import pytest
6+
from pygitguardian.models import HealthCheckResponse
67
from pytest_voluptuous import S
7-
from voluptuous.validators import All, Match
8+
from voluptuous.validators import All, In, Match
89

910
from ggshield.__main__ import cli
11+
from ggshield.core.config.config import ConfigSource
12+
from ggshield.utils.os import cd
1013
from tests.unit.conftest import assert_invoke_ok, my_vcr
1114

1215

@@ -40,13 +43,77 @@ def test_api_status(cli_fs_runner, api_status_json_schema):
4043
"status_code": 200,
4144
"app_version": Match(r"v\d\.\d{1,3}\.\d{1,2}(-rc\.\d)?"),
4245
"secrets_engine_version": Match(r"\d\.\d{1,3}\.\d"),
46+
"instance_source": In(x.name for x in ConfigSource),
47+
"api_key_source": In(x.name for x in ConfigSource),
4348
}
4449
)
4550
)
4651
== dct
4752
)
4853

4954

55+
@mock.patch(
56+
"ggshield.core.config.auth_config.AuthConfig.get_instance_token",
57+
return_value="token",
58+
)
59+
@mock.patch(
60+
"pygitguardian.GGClient.health_check",
61+
return_value=HealthCheckResponse(detail="", status_code=200),
62+
)
63+
def test_api_status_sources(_, hs_mock, cli_fs_runner, tmp_path, monkeypatch):
64+
"""
65+
GIVEN an api_key and an instance configured anywhere
66+
WHEN running the api-status command
67+
THEN the correct api key and instance source are returned
68+
"""
69+
(tmp_path / ".env").touch()
70+
71+
def get_api_status(env, instance=None):
72+
with cd(tmp_path):
73+
cmd = ["api-status", "--json"]
74+
if instance:
75+
cmd.extend(["--instance", instance])
76+
result = cli_fs_runner.invoke(cli, cmd, color=False, env=env)
77+
78+
json_res = json.loads(result.output)
79+
return json_res["instance_source"], json_res["api_key_source"]
80+
81+
env: dict[str, str | None] = {
82+
"GITGUARDIAN_INSTANCE": None,
83+
"GITGUARDIAN_URL": None,
84+
"GITGUARDIAN_API_KEY": None,
85+
}
86+
instance_source, api_key_source = get_api_status(env)
87+
assert instance_source == ConfigSource.DEFAULT.name
88+
assert api_key_source == ConfigSource.USER_CONFIG.name
89+
90+
(tmp_path / ".gitguardian.yaml").write_text(
91+
"version: 2\ninstance: https://dashboard.gitguardian.com\n"
92+
)
93+
instance_source, api_key_source = get_api_status(env)
94+
assert instance_source == ConfigSource.USER_CONFIG.name
95+
assert api_key_source == ConfigSource.USER_CONFIG.name
96+
97+
env["GITGUARDIAN_INSTANCE"] = "https://dashboard.gitguardian.com"
98+
env["GITGUARDIAN_API_KEY"] = "token"
99+
instance_source, api_key_source = get_api_status(env)
100+
assert instance_source == ConfigSource.ENV_VAR.name
101+
assert api_key_source == ConfigSource.ENV_VAR.name
102+
103+
(tmp_path / ".env").write_text(
104+
"GITGUARDIAN_INSTANCE=https://dashboard.gitguardian.com\n"
105+
"GITGUARDIAN_API_KEY=token"
106+
)
107+
instance_source, api_key_source = get_api_status(env)
108+
assert instance_source == ConfigSource.DOTENV.name
109+
assert api_key_source == ConfigSource.DOTENV.name
110+
111+
assert (
112+
get_api_status(env, instance="https://dashboard.gitguardian.com")[0]
113+
== ConfigSource.CMD_OPTION.name
114+
)
115+
116+
50117
@pytest.mark.parametrize("verify", [True, False])
51118
def test_ssl_verify(cli_fs_runner, verify):
52119
cmd = ["api-status"] if verify else ["--allow-self-signed", "api-status"]

tests/unit/core/test_env_utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55

6-
from ggshield.core.env_utils import load_dot_env
6+
from ggshield.core.env_utils import TRACKED_ENV_VARS, load_dot_env
77
from ggshield.utils.os import cd
88

99

@@ -84,3 +84,18 @@ def test_load_dot_env_loads_git_root_env(
8484
with cd(sub1_sub2_dir):
8585
load_dot_env()
8686
load_dotenv_mock.assert_called_once_with(git_root_dotenv, override=True)
87+
88+
89+
@pytest.mark.parametrize("env_var", TRACKED_ENV_VARS)
90+
def test_load_dot_env_returns_set_vars(env_var, tmp_path, monkeypatch):
91+
"""
92+
GIVEN an env var that is set, and also set with the same value in the .env
93+
WHEN load_dot_env() is called
94+
THEN it returns the env var
95+
"""
96+
monkeypatch.setenv(env_var, "value")
97+
(tmp_path / ".env").write_text(f"{env_var}=value")
98+
with cd(tmp_path):
99+
set_variables = load_dot_env()
100+
101+
assert set_variables == {env_var}

0 commit comments

Comments
 (0)