diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 36cc07f5af..77942aa866 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -default_stages: [commit] +default_stages: [pre-commit] repos: - repo: https://github.com/ambv/black rev: 24.3.0 @@ -68,7 +68,7 @@ repos: name: GitGuardian Shield entry: pipenv run ggshield secret scan pre-commit language: system - stages: [commit] + stages: [pre-commit] - repo: local hooks: @@ -77,14 +77,14 @@ repos: entry: pipenv run ggshield secret scan pre-push language: system pass_filenames: false - stages: [push] + stages: [pre-push] - repo: https://github.com/gitguardian/ggshield rev: v1.32.0 hooks: - id: ggshield language_version: python3 - stages: [commit] + stages: [pre-commit] - repo: local hooks: @@ -94,7 +94,7 @@ repos: language: system pass_filenames: false types: [python] - stages: [commit] + stages: [pre-commit] - id: import-linter name: Import Linter @@ -102,4 +102,4 @@ repos: language: system pass_filenames: false types: [python] - stages: [commit] + stages: [pre-commit] diff --git a/changelog.d/20241028_145054_salome.voltz_refacto_json_output.md b/changelog.d/20241028_145054_salome.voltz_refacto_json_output.md new file mode 100644 index 0000000000..9f68a9b030 --- /dev/null +++ b/changelog.d/20241028_145054_salome.voltz_refacto_json_output.md @@ -0,0 +1,7 @@ +### Added + +- `ggshield config list` command now supports the `--json` option, allowing output in JSON format. + +### Changed + +- `ggshield api-status --json` now also outputs the instance URL. diff --git a/doc/schemas/api-status.json b/doc/schemas/api-status.json index f341a8e811..b26864d8ae 100644 --- a/doc/schemas/api-status.json +++ b/doc/schemas/api-status.json @@ -8,6 +8,11 @@ "minimum": 200, "description": "HTTP status code for the request" }, + "instance": { + "type": "string", + "format": "uri", + "description": "URL of the GitGuardian instance" + }, "detail": { "type": "string", "description": "Human-readable version of the status" @@ -21,5 +26,11 @@ "description": "Version of the secrets engine" } }, - "required": ["status_code", "detail", "app_version", "secrets_engine_version"] + "required": [ + "status_code", + "instance", + "detail", + "app_version", + "secrets_engine_version" + ] } diff --git a/doc/schemas/config_list.json b/doc/schemas/config_list.json new file mode 100644 index 0000000000..f2ee17c123 --- /dev/null +++ b/doc/schemas/config_list.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ggshield config list", + "type": "object", + "properties": { + "instances": { + "type": "array", + "items": { + "type": "object", + "properties": { + "instance_name": { + "type": "string", + "description": "Name of the GitGuardian instance" + }, + "default_token_lifetime": { + "type": ["string", "null"], + "description": "Default token lifetime" + }, + "workspace_id": { + "type": ["number", "string"], + "description": "Workspace ID" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the GitGuardian instance" + }, + "token": { + "type": "string", + "description": "API Token for the instance" + }, + "token_name": { + "type": "string", + "description": "Name of the token" + }, + "expiry": { + "type": "string", + "description": "Expiration date of the token" + } + }, + "required": [ + "instance_name", + "workspace_id", + "url", + "token", + "token_name", + "expiry" + ] + } + }, + "global_values": { + "type": "object", + "properties": { + "instance": { + "type": ["string", "null"], + "description": "Name of the default GitGuardian instance" + }, + "default_token_lifetime": { + "type": ["string", "null"], + "description": "Default token lifetime" + } + }, + "required": ["instance", "default_token_lifetime"] + } + }, + "required": ["instances", "global_values"] +} diff --git a/ggshield/cmd/config/config_list.py b/ggshield/cmd/config/config_list.py index 51e0caee4a..c5eacaaa3f 100644 --- a/ggshield/cmd/config/config_list.py +++ b/ggshield/cmd/config/config_list.py @@ -1,69 +1,112 @@ -from typing import Any, Tuple +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional import click -from ggshield.cmd.utils.common_options import add_common_options +from ggshield.cmd.utils.common_options import ( + add_common_options, + json_option, + text_json_format_option, +) from ggshield.cmd.utils.context_obj import ContextObj +from ggshield.core.config.auth_config import InstanceConfig from .constants import DATETIME_FORMAT, FIELDS +@dataclass +class InstanceInfo: + instance_name: str + default_token_lifetime: Optional[int] + workspace_id: Any + url: str + token: str + token_name: str + expiry: str + + +@dataclass +class ConfigData: + instances: List[InstanceInfo] = field(default_factory=list) + global_values: Dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> Dict[str, Any]: + return { + "instances": [instance.__dict__ for instance in self.instances], + "global_values": self.global_values, + } + + +def get_instance_info( + instance: InstanceConfig, default_token_lifetime: Any +) -> InstanceInfo: + """Helper function to extract instance information.""" + instance_name = instance.name or instance.url + account = instance.account + + if account is not None: + workspace_id = account.workspace_id + token = account.token + token_name = account.token_name + expire_at = account.expire_at + expiry = expire_at.strftime(DATETIME_FORMAT) if expire_at else "never" + else: + workspace_id = token = token_name = expiry = "not set" + + _default_token_lifetime = instance.default_token_lifetime or default_token_lifetime + + return InstanceInfo( + instance_name=instance_name, + default_token_lifetime=_default_token_lifetime, + workspace_id=workspace_id, + url=instance.url, + token=token, + token_name=token_name, + expiry=expiry, + ) + + @click.command() @click.pass_context +@json_option +@text_json_format_option @add_common_options() def config_list_cmd(ctx: click.Context, **kwargs: Any) -> int: """ Print the list of configuration keys and values. """ - config = ContextObj.get(ctx).config + ctx_obj = ContextObj.get(ctx) + config = ctx_obj.config default_token_lifetime = config.auth_config.default_token_lifetime - message_lines = [] - - def add_entries(*entries: Tuple[str, Any]): - for key, value in entries: - message_lines.append(f"{key}: {value}") - - # List global values - for field in FIELDS.values(): - config_obj = config.auth_config if field.auth_config else config.user_config - value = getattr(config_obj, field.name) - add_entries((field.name, value)) - message_lines.append("") - - # List instance values - for instance in config.auth_config.instances: - instance_name = instance.name or instance.url - - if instance.account is not None: - workspace_id = instance.account.workspace_id - token = instance.account.token - token_name = instance.account.token_name - expire_at = instance.account.expire_at - expiry = ( - expire_at.strftime(DATETIME_FORMAT) - if expire_at is not None - else "never" - ) - else: - workspace_id = token = token_name = expiry = "not set" - - _default_token_lifetime = ( - instance.default_token_lifetime - if instance.default_token_lifetime is not None - else default_token_lifetime + config_data = ConfigData() + for config_field in FIELDS.values(): + config_obj = ( + config.auth_config if config_field.auth_config else config.user_config ) + value = getattr(config_obj, config_field.name) + config_data.global_values[config_field.name] = value - message_lines.append(f"[{instance_name}]") - add_entries( - ("default_token_lifetime", _default_token_lifetime), - ("workspace_id", workspace_id), - ("url", instance.url), - ("token", token), - ("token_name", token_name), - ("expiry", expiry), - ) + config_data.instances = [ + get_instance_info(instance, default_token_lifetime) + for instance in config.auth_config.instances + ] + + if ctx_obj.use_json: + click.echo(json.dumps(config_data.as_dict())) + else: + message_lines = [ + f"{key}: {value}" for key, value in config_data.global_values.items() + ] message_lines.append("") + for instance in config_data.instances: + message_lines.append(f"[{instance.instance_name}]") + for key, value in instance.__dict__.items(): + if key != "instance_name": + message_lines.append(f"{key}: {value}") + message_lines.append("") + + click.echo("\n".join(message_lines).strip()) - click.echo("\n".join(message_lines).strip()) return 0 diff --git a/ggshield/cmd/status.py b/ggshield/cmd/status.py index f9e33a7fac..0359a1ee64 100644 --- a/ggshield/cmd/status.py +++ b/ggshield/cmd/status.py @@ -1,4 +1,5 @@ #!/usr/bin/python3 +import json from typing import Any import click @@ -29,17 +30,18 @@ def status_cmd(ctx: click.Context, **kwargs: Any) -> int: if not isinstance(response, HealthCheckResponse): raise UnexpectedError("Unexpected health check response") - click.echo( - response.to_json() - if ctx_obj.use_json - else ( + if ctx_obj.use_json: + json_output = response.to_dict() + json_output["instance"] = client.base_uri + click.echo(json.dumps(json_output)) + else: + click.echo( f"{format_text('API URL:', STYLE['key'])} {client.base_uri}\n" f"{format_text('Status:', STYLE['key'])} {format_healthcheck_status(response)}\n" f"{format_text('App version:', STYLE['key'])} {response.app_version or 'Unknown'}\n" f"{format_text('Secrets engine version:', STYLE['key'])} " f"{response.secrets_engine_version or 'Unknown'}\n" ) - ) return 0 diff --git a/tests/conftest.py b/tests/conftest.py index 6c841395ad..168c1c1e6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -318,6 +318,12 @@ def api_status_json_schema() -> Dict[str, Any]: return _load_json_schema("api-status.json") +@pytest.fixture(scope="session") +def config_list_json_schema() -> Dict[str, Any]: + """Load the JSON schema for `config list` command.""" + return _load_json_schema("config_list.json") + + @pytest.fixture(scope="session") def sca_scan_all_json_schema() -> Dict[str, Any]: """Load the JSON schema for `sca scan all` command.""" diff --git a/tests/unit/cmd/test_config.py b/tests/unit/cmd/test_config.py index 8b4e686be9..d712bc15d5 100644 --- a/tests/unit/cmd/test_config.py +++ b/tests/unit/cmd/test_config.py @@ -1,7 +1,12 @@ +import json from datetime import datetime, timezone from typing import Tuple +import jsonschema import pytest +from pytest_voluptuous import S +from voluptuous import Any, In +from voluptuous.validators import All, Match from ggshield.__main__ import cli from ggshield.core.config import Config @@ -45,14 +50,13 @@ class TestConfigList: - def test_valid_list(self, cli_fs_runner): + + @pytest.fixture + def setup_configs(self, cli_fs_runner): """ - GIVEN several config saved - WHEN calling `ggshield config list` command - THEN all configs should be listed with the correct format + Set up multiple instance configs for tests. + This fixture runs before each test method in this class. """ - - # May 4th some_date = datetime(2022, 5, 4, 17, 0, 0, 0, tzinfo=timezone.utc) add_instance_config(expiry_date=some_date) @@ -67,14 +71,58 @@ def test_valid_list(self, cli_fs_runner): expiry_date=some_date, ) + def test_valid_list(self, cli_fs_runner, setup_configs): + """ + GIVEN several config saved + WHEN calling `ggshield config list` command + THEN all configs should be listed with the correct format + """ exit_code, output = self.run_cmd(cli_fs_runner) assert exit_code == ExitCode.SUCCESS, output assert output == EXPECTED_OUTPUT + def test_list_json_output( + self, cli_fs_runner, config_list_json_schema, setup_configs + ): + """ + GIVEN several config saved + WHEN calling `ggshield config list` command + THEN all configs should be listed with the correct format + """ + exit_code_json, output_json = self.run_cmd(cli_fs_runner, json=True) + + assert exit_code_json == ExitCode.SUCCESS, output_json + dct = json.loads(output_json) + jsonschema.validate(dct, config_list_json_schema) + assert ( + S( + All( + { + "instances": [ + { + "instance_name": str, + "default_token_lifetime": Any(None, str), + "workspace_id": In([1, "not set"]), + "url": Match(r"https://[^\s]+\.com"), + "token": str, + "token_name": str, + "expiry": Match(r"2022-05-04T17:00:00Z|not set"), + } + ], + "global_values": { + "instance": Any(None, str), + "default_token_lifetime": Any(None, str), + }, + } + ) + ) + == dct + ) + @staticmethod - def run_cmd(cli_fs_runner) -> Tuple[bool, str]: - cmd = ["config", "list"] + def run_cmd(cli_fs_runner, json: bool = False) -> Tuple[bool, str]: + cmd = ["config", "list", "--json"] if json else ["config", "list"] result = cli_fs_runner.invoke(cli, cmd, color=False, catch_exceptions=False) return result.exit_code, result.output diff --git a/tests/unit/cmd/test_status.py b/tests/unit/cmd/test_status.py index b610a192ee..35877b88f7 100644 --- a/tests/unit/cmd/test_status.py +++ b/tests/unit/cmd/test_status.py @@ -36,6 +36,7 @@ def test_api_status(cli_fs_runner, api_status_json_schema): All( { "detail": "Valid API key.", + "instance": Match(r"https://[^\s]+"), "status_code": 200, "app_version": Match(r"v\d\.\d{1,3}\.\d{1,2}(-rc\.\d)?"), "secrets_engine_version": Match(r"\d\.\d{1,3}\.\d"),