diff --git a/README.md b/README.md index fc8d296..61fb97b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ jobs: | `app_path` | Path to the directory where addon is located, without filename | **required** | | | `included_tags` | Comma separated list of [tags](#reference-docs) to include in appinspect job | | None | | `excluded_tags` | Comma separated list of [tags](#reference-docs) to exclude from appinspect job | | None | +| `log_level` | Python logging level for action | | `INFO` | You can explicitly include and exclude tags from a validation by including additional options in your request. Specifically, using the included_tags and excluded_tags options includes and excludes the tags you specify from a validation. If no tags are specified all checks will be done and no tags are excluded from the validation. diff --git a/action.yml b/action.yml index 14cbedb..2a418cb 100644 --- a/action.yml +++ b/action.yml @@ -19,6 +19,10 @@ inputs: description: comma seperated list of tags to be excluded from appinspect scans default: "" required: false + log_level: + description: severity for python logging ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + default: "INFO" + required: false runs: using: "docker" - image: docker://ghcr.io/splunk/appinspect-api-action/appinspect-api-action:v3.0.0 + image: Dockerfile diff --git a/entrypoint.sh b/entrypoint.sh index 33b4c4a..823de7f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,6 +3,6 @@ ADDON_NAME=$(ls $INPUT_APP_PATH) ADDON_FULL_PATH="$INPUT_APP_PATH/$ADDON_NAME" -echo "$INPUT_USERNAME" "$INPUT_PASSWORD" "$ADDON_FULL_PATH" "$INPUT_INCLUDED_TAGS" "$INPUT_EXCLUDED_TAGS" +echo "$INPUT_USERNAME" "$INPUT_PASSWORD" "$ADDON_FULL_PATH" "$INPUT_INCLUDED_TAGS" "$INPUT_EXCLUDED_TAGS" "$INPUT_LOG_LEVEL" -python3 /main.py "$INPUT_USERNAME" "$INPUT_PASSWORD" "$ADDON_FULL_PATH" "$INPUT_INCLUDED_TAGS" "$INPUT_EXCLUDED_TAGS" \ No newline at end of file +python3 /main.py "$INPUT_USERNAME" "$INPUT_PASSWORD" "$ADDON_FULL_PATH" "$INPUT_INCLUDED_TAGS" "$INPUT_EXCLUDED_TAGS" "$INPUT_LOG_LEVEL" \ No newline at end of file diff --git a/main.py b/main.py index c19edae..22dda5c 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ import random import json import yaml +import logging from pathlib import Path from typing import Dict, Any, Tuple, Callable, Sequence, Optional, List @@ -46,10 +47,12 @@ def _retry_request( for retry_num in range(num_retries): if retry_num > 0: sleep_time = rand() + retry_num - print( + logging.info( f"Sleeping {sleep_time} seconds before retry " - f"{retry_num} of {num_retries - 1} after {reason}" + f"{retry_num} of {num_retries - 1}" ) + if reason: + logging.info(reason) sleep(sleep_time) response = requests.request( method, @@ -70,7 +73,7 @@ def _retry_request( reason = f"response status code: {response.status_code}, for message: {error_message}" continue if not validation_function(response): - print("Response did not pass the validation, retrying...") + logging.info("Response did not pass the validation, retrying...") continue return response raise CouldNotRetryRequestException() @@ -93,6 +96,7 @@ def _download_report( def login(username: str, password: str) -> requests.Response: + logging.debug("Sending request to retrieve login token") try: return _retry_request( "GET", @@ -100,10 +104,10 @@ def login(username: str, password: str) -> requests.Response: auth=(username, password), ) except CouldNotAuthenticateException: - print("Credentials are not correct, please check the configuration.") + logging.error("Credentials are not correct, please check the configuration.") sys.exit(1) except CouldNotRetryRequestException: - print("Could not get response after all retries, exiting...") + logging.error("Could not get response after all retries, exiting...") sys.exit(1) @@ -114,6 +118,7 @@ def validate(token: str, build: Path, payload: Dict[str, str]) -> requests.Respo (build.name, open(build.as_posix(), "rb"), "application/octet-stream"), ) ] + logging.debug(f"Sending package `{build.name}` for validation") try: response = _retry_request( "POST", @@ -126,22 +131,28 @@ def validate(token: str, build: Path, payload: Dict[str, str]) -> requests.Respo ) return response except CouldNotAuthenticateException: - print("Credentials are not correct, please check the configuration.") + logging.error("Credentials are not correct, please check the configuration.") sys.exit(1) except CouldNotRetryRequestException: - print("Could not get response after all retries, exiting...") + logging.error("Could not get response after all retries, exiting...") sys.exit(1) def submit(token: str, request_id: str) -> requests.Response: def _validate_validation_status(response: requests.Response) -> bool: - return response.json()["status"] == "SUCCESS" + is_successful = response.json()["status"] == "SUCCESS" + if is_successful: + logging.debug( + f'Response status is `{response.json()["status"]}`, "SUCCESS" expected.' + ) + return is_successful # appinspect api needs some time to process the request # if the response status will be "PROCESSING" wait 60s and make another call # there is a problem with pycov marking this line as not covered - excluded from coverage try: + logging.debug("Submitting package") return _retry_request( # pragma: no cover "GET", f"https://appinspect.splunk.com/v1/app/validate/status/{request_id}", @@ -153,22 +164,24 @@ def _validate_validation_status(response: requests.Response) -> bool: validation_function=_validate_validation_status, ) except CouldNotAuthenticateException: - print("Credentials are not correct, please check the configuration.") + logging.error("Credentials are not correct, please check the configuration.") sys.exit(1) except CouldNotRetryRequestException: - print("Could not get response after all retries, exiting...") + logging.error("Could not get response after all retries, exiting...") sys.exit(1) def download_json_report( token: str, request_id: str, payload: Dict[str, Any] ) -> requests.Response: + logging.debug("Downloading response in json format") return _download_report( token=token, request_id=request_id, payload=payload, response_type="json" ) def download_and_save_html_report(token: str, request_id: str, payload: Dict[str, Any]): + logging.debug("Downloading report in html format") response = _download_report( token=token, request_id=request_id, payload=payload, response_type="html" ) @@ -178,6 +191,7 @@ def download_and_save_html_report(token: str, request_id: str, payload: Dict[str def get_appinspect_failures_list(response_dict: Dict[str, Any]) -> List[str]: + logging.debug("Parsing json response to find failed checks\n") reports = response_dict["reports"] groups = reports[0]["groups"] @@ -187,7 +201,7 @@ def get_appinspect_failures_list(response_dict: Dict[str, Any]) -> List[str]: for check in group["checks"]: if check["result"] == "failure": failed_tests_list.append(check["name"]) - print(f"Failed appinspect check for name: {check['name']}\n") + logging.debug(f"Failed appinspect check for name: {check['name']}\n") return failed_tests_list @@ -196,26 +210,24 @@ def read_yaml_as_dict(filename_path: Path) -> Dict[str, str]: try: out_dict = yaml.safe_load(file) except yaml.YAMLError as e: - print(f"Can not read yaml file named {filename_path}") + logging.error(f"Can not read yaml file named {filename_path}") raise e return out_dict if out_dict else {} def compare_failures(failures: List[str], expected: List[str]): if sorted(failures) != sorted(expected): - print( - "Appinspect failures doesn't match appinspect.expect file, check for exceptions file" - ) + logging.debug(f"Appinspect failures: {failures}") + logging.debug(f"Expected failures: {expected}") raise AppinspectFailures def parse_results(results: Dict[str, Any]): - print(results) print("\n======== AppInspect Api Results ========") for metric, count in results["info"].items(): print(f"{metric:>15} : {count: <4}") if results["info"]["error"] > 0 or results["info"]["failure"] > 0: - print("\nError or failures found in App Inspect\n") + logging.warning("Error or failures found in AppInspect Report") raise AppinspectChecksFailuresException @@ -230,14 +242,23 @@ def build_payload(included_tags: str, excluded_tags: str) -> Dict[str, str]: def compare_against_known_failures(response_json: Dict[str, Any], exceptions_file_path): + logging.info( + f"Comparing AppInspect Failures with `{exceptions_file_path.name}` file" + ) failures = get_appinspect_failures_list(response_json) if exceptions_file_path.exists(): expected_failures = list(read_yaml_as_dict(exceptions_file_path).keys()) - compare_failures(failures, expected_failures) + try: + compare_failures(failures, expected_failures) + except AppinspectFailures: + logging.error( + "Appinspect failures don't match appinspect.expect file, check for exceptions file" + ) + sys.exit(1) else: - print( - f"ERROR: File `{exceptions_file_path.name}` not found, please create `{exceptions_file_path.name}` file with exceptions\n" # noqa: E501 + logging.error( + f"File `{exceptions_file_path.name}` not found, please create `{exceptions_file_path.name}` file with exceptions\n" # noqa: E501 ) sys.exit(1) @@ -251,27 +272,33 @@ def main(argv: Optional[Sequence[str]] = None): parser.add_argument("app_path") parser.add_argument("included_tags") parser.add_argument("excluded_tags") + parser.add_argument("log_level") appinspect_expect_filename = ".appinspect_api.expect.yaml" args = parser.parse_args(argv) - print( + logging.basicConfig(level=args.log_level) + + logging.info( f"app_path={args.app_path}, included_tags={args.included_tags}, excluded_tags={args.excluded_tags}" ) build = Path(args.app_path) login_response = login(args.username, args.password) token = login_response.json()["data"]["token"] - print("Successfully received token") + logging.debug("Successfully received token") payload = build_payload(args.included_tags, args.excluded_tags) + logging.debug(f"Validation payload: {payload}") validate_response = validate(token, build, payload) - print(f"Successfully sent package for validation using {payload}") + logging.debug(f"Successfully sent package for validation using {payload}") request_id = validate_response.json()["request_id"] submit_response = submit(token, request_id) + logging.info("Successfully submitted and validated package") + download_and_save_html_report(token, request_id, payload) # if this is true it compares the exceptions and results diff --git a/test/unit/test_main.py b/test/unit/test_main.py index 14f94ec..f72bdc1 100644 --- a/test/unit/test_main.py +++ b/test/unit/test_main.py @@ -34,28 +34,23 @@ def test_login_success(mock_requests): @mock.patch.object(main, "_retry_request") -def test_login_when_credentials_are_not_ok(mock_retry_request, capsys): +def test_login_when_credentials_are_not_ok(mock_retry_request, caplog): mock_retry_request.side_effect = main.CouldNotAuthenticateException with pytest.raises(SystemExit): main.login("username", "password") - captured = capsys.readouterr() - - assert ( - captured.out == "Credentials are not correct, please check the configuration.\n" - ) + assert "Credentials are not correct, please check the configuration." in caplog.text @mock.patch.object(main, "_retry_request") -def test_login_cant_retry_request(mock_retry_request, capsys): +def test_login_cant_retry_request(mock_retry_request, caplog): mock_retry_request.side_effect = main.CouldNotRetryRequestException with pytest.raises(SystemExit): main.login("username", "password") - captured = capsys.readouterr() - assert captured.out == "Could not get response after all retries, exiting...\n" + assert "Could not get response after all retries, exiting...\n" in caplog.text @mock.patch("main.requests") @@ -89,7 +84,7 @@ def test_validate_success(mock_requests, tmp_path): @mock.patch.object(main, "_retry_request") -def test_validate_invalid_token(mock_retry_request, capsys, tmp_path): +def test_validate_invalid_token(mock_retry_request, caplog, tmp_path): mock_retry_request.side_effect = main.CouldNotAuthenticateException file = tmp_path / "test.spl" @@ -98,14 +93,13 @@ def test_validate_invalid_token(mock_retry_request, capsys, tmp_path): with pytest.raises(SystemExit): main.validate(token="token", build=file, payload={}) - captured = capsys.readouterr() assert ( - captured.out == "Credentials are not correct, please check the configuration.\n" + "Credentials are not correct, please check the configuration.\n" in caplog.text ) @mock.patch.object(main, "_retry_request") -def test_validate_count_retry(mock_retry_request, capsys, tmp_path): +def test_validate_count_retry(mock_retry_request, caplog, tmp_path): mock_retry_request.side_effect = main.CouldNotRetryRequestException file = tmp_path / "test.spl" @@ -114,8 +108,7 @@ def test_validate_count_retry(mock_retry_request, capsys, tmp_path): with pytest.raises(SystemExit): main.validate(token="token", build=file, payload={}) - captured = capsys.readouterr() - assert captured.out == "Could not get response after all retries, exiting...\n" + assert "Could not get response after all retries, exiting...\n" in caplog.text @mock.patch("main.requests") @@ -155,27 +148,25 @@ def test_submit_success(mock_requests): @mock.patch.object(main, "_retry_request") -def test_submit_invalid_token(mock_retry_request, capsys): +def test_submit_invalid_token(mock_retry_request, caplog): mock_retry_request.side_effect = main.CouldNotAuthenticateException with pytest.raises(SystemExit): main.submit(token="invalid_token", request_id="1234-1234") - captured = capsys.readouterr() assert ( - captured.out == "Credentials are not correct, please check the configuration.\n" + "Credentials are not correct, please check the configuration.\n" in caplog.text ) @mock.patch.object(main, "_retry_request") -def test_submit_cant_retry_request(mock_retry_request, capsys): +def test_submit_cant_retry_request(mock_retry_request, caplog): mock_retry_request.side_effect = main.CouldNotRetryRequestException with pytest.raises(SystemExit): main.submit(token="invalid_token", request_id="1234-1234") - captured = capsys.readouterr() - assert captured.out == "Could not get response after all retries, exiting...\n" + assert "Could not get response after all retries, exiting...\n" in caplog.text @pytest.mark.parametrize( @@ -192,33 +183,7 @@ def test_build_payload(included, excluded, payload): assert test_payload == payload -# @mock.patch("main.requests") -# def test_download_html(mock_requests): -# mock_response = mock.MagicMock() -# -# sample_html = """ -# -# -# -# Sample HTML -# -# -#

This is sample HTML

-# -# -# """ -# -# mock_response.text = sample_html -# mock_response.status_code = 200 -# mock_requests.request.return_value = mock_response -# -# main.download_html_report("token", "123-123-123", {}) -# -# with open("./AppInspect_response.html") as test_output: -# assert test_output.read() == sample_html - - -def test_parse_results_errors(capsys): +def test_parse_results_errors(): results = {"info": {"error": 1, "failure": 1}} with pytest.raises(main.AppinspectChecksFailuresException): main.parse_results(results) @@ -230,11 +195,14 @@ def test_parse_results_no_errors(capsys): main.parse_results(results) captured = capsys.readouterr() - assert "{'info': {'error': 0, 'failure': 0}}\n" in captured.out + assert ( + "\n======== AppInspect Api Results ========\n error : 0 \n failure : 0 \n" + in captured.out + ) @mock.patch("main.requests") -def test_retry_request_always_400(mock_requests, capsys): +def test_retry_request_always_400(mock_requests): mock_response = mock.MagicMock() response_input_json = {"msg": "Invalid request"} mock_response.json.return_value = response_input_json @@ -246,16 +214,9 @@ def test_retry_request_always_400(mock_requests, capsys): method="GET", url="http://test", sleep=lambda _: 0.0, rand=lambda: 0.0 ) - captured = capsys.readouterr() - - assert ( - "Sleeping 1.0 seconds before retry 1 of 2 after response status code: 400, for message: Invalid request\n" - in captured.out - ) - @mock.patch("main.requests") -def test_retry_request_message_key_in_response(mock_requests, capsys): +def test_retry_request_message_key_in_response(mock_requests): mock_response = mock.MagicMock() response_input_json = {"message": "message key instead of msg"} mock_response.json.return_value = response_input_json @@ -270,9 +231,6 @@ def test_retry_request_message_key_in_response(mock_requests, capsys): rand=lambda: 0.0, ) - captured = capsys.readouterr() - assert "message key instead of msg" in captured.out - @mock.patch("main.requests") def test_retry_request_error_401(mock_requests, capsys): @@ -287,7 +245,7 @@ def test_retry_request_error_401(mock_requests, capsys): @mock.patch("main.requests") -def test_retry_request_did_not_pass_validation(mock_requests, capsys): +def test_retry_request_did_not_pass_validation(mock_requests): mock_response = mock.MagicMock() response_input_json = {"message": "message key instead of msg"} mock_response.json.return_value = response_input_json @@ -303,13 +261,9 @@ def test_retry_request_did_not_pass_validation(mock_requests, capsys): validation_function=lambda _: False, ) - captured = capsys.readouterr() - - assert "Response did not pass the validation, retrying..." in captured.out - @mock.patch("main.requests") -def test_retry_request_501_then_200(mock_request, capsys): +def test_retry_request_501_then_200(mock_request): mock_response_501 = mock.MagicMock() response_input_json_501 = {"status_code": 501, "message": "should be retried"} mock_response_501.json.return_value = response_input_json_501 @@ -337,13 +291,7 @@ def test_retry_request_501_then_200(mock_request, capsys): response = main._retry_request("user", "password") - captured = capsys.readouterr() - assert response.status_code == 200 - assert ( - "retry 1 of 2 after response status code: 501, for message: should be retried" - in captured.out - ) @mock.patch("main.download_and_save_html_report") @@ -439,7 +387,7 @@ def test_main_errors_in_except_file( download_mock_response.status_code = 200 mock_download_and_save_html_report.request.return_value = download_mock_response - main.main(["user", "pass", "build", "i_tag", "e_tag"]) + main.main(["user", "pass", "build", "i_tag", "e_tag", "DEBUG"]) @mock.patch("main.download_json_report") @@ -553,7 +501,7 @@ def test_main_failures_file_does_not_exist( mock_download_json_report.return_value = mock_json_response with pytest.raises(SystemExit): - main.main(["user", "pass", "build", "i_tag", "e_tag"]) + main.main(["user", "pass", "build", "i_tag", "e_tag", "DEBUG"]) @mock.patch("main.validate") @@ -583,7 +531,7 @@ def test_main_invalid_token(mock_login, mock_validate): mock_validate.side_effect = main.CouldNotAuthenticateException with pytest.raises(main.CouldNotAuthenticateException): - main.main(["user", "pass", "build", "i_tag", "e_tag"]) + main.main(["user", "pass", "build", "i_tag", "e_tag", "DEBUG"]) @mock.patch("main.login") @@ -591,7 +539,7 @@ def test_main_api_down_cant_retry_request(mock_login): mock_login.side_effect = main.CouldNotRetryRequestException with pytest.raises(main.CouldNotRetryRequestException): - main.main(["user", "pass", "build", "i_tag", "e_tag"]) + main.main(["user", "pass", "build", "i_tag", "e_tag", "DEBUG"]) @mock.patch("main.requests") @@ -721,7 +669,7 @@ def test_compare_failures_fails(): @mock.patch("yaml.safe_load") -def test_read_yaml_as_dict_incorrect_yaml(mock_safe_load, capsys, tmp_path): +def test_read_yaml_as_dict_incorrect_yaml(mock_safe_load, caplog, tmp_path): mock_safe_load.side_effect = yaml.YAMLError file_path = tmp_path / "foo.yaml" file_path.write_text("test") @@ -729,8 +677,7 @@ def test_read_yaml_as_dict_incorrect_yaml(mock_safe_load, capsys, tmp_path): with pytest.raises(yaml.YAMLError): main.read_yaml_as_dict(file_path) - captured = capsys.readouterr() - assert captured.out == f"Can not read yaml file named {file_path}\n" + assert f"Can not read yaml file named {file_path}\n" in caplog.text def test_compare_known_failures_no_exceptions(tmp_path): @@ -752,7 +699,7 @@ def test_compare_known_failures_no_exceptions(tmp_path): exceptions_file = tmp_path / "foo.yaml" exceptions_file.write_text(exceptions_content) - with pytest.raises(main.AppinspectFailures): + with pytest.raises(SystemExit): main.compare_against_known_failures(response_json, exceptions_file)