diff --git a/swh/icinga_plugins/tests/test_vault.py b/swh/icinga_plugins/tests/test_vault.py index 276af4d..7398590 100644 --- a/swh/icinga_plugins/tests/test_vault.py +++ b/swh/icinga_plugins/tests/test_vault.py @@ -1,381 +1,427 @@ # Copyright (C) 2019-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import time from swh.icinga_plugins.tests.utils import invoke from .web_scenario import WebScenario DIR_ID = "ab" * 20 url_api = f"mock://swh-web.example.org/api/1/vault/directory/{DIR_ID}/" url_fetch = f"mock://swh-web.example.org/api/1/vault/directory/{DIR_ID}/raw/" response_pending = { "obj_id": DIR_ID, "obj_type": "directory", "progress_message": "foo", "status": "pending", } response_done = { "fetch_url": url_fetch, "id": 9, "obj_id": DIR_ID, "obj_type": "directory", "status": "done", } response_done_no_fetch = { "id": 9, "obj_id": DIR_ID, "obj_type": "directory", "status": "done", } response_failed = { "obj_id": DIR_ID, "obj_type": "directory", "progress_message": "foobar", "status": "failed", } response_unknown_status = { "obj_id": DIR_ID, "obj_type": "directory", "progress_message": "what", "status": "boo", } class FakeStorage: def __init__(self, foo, **kwargs): pass def directory_get_random(self): return bytes.fromhex(DIR_ID) def test_vault_immediate_success(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_done) - scenario.add_step("get", url_fetch, "xx" * 40) + scenario.add_step( + "get", url_fetch, "xx" * 40, headers={"Content-Type": "application/gzip"} + ) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ] ) assert result.output == ( f"VAULT OK - cooking directory {DIR_ID} took " f"10.00s and succeeded.\n" f"| 'total_time' = 10.00s\n" ) assert result.exit_code == 0, result.output def test_vault_delayed_success(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_pending) scenario.add_step("get", url_api, response_done) - scenario.add_step("get", url_fetch, "xx" * 40) + scenario.add_step( + "get", url_fetch, "xx" * 40, headers={"Content-Type": "application/gzip"} + ) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ] ) assert result.output == ( f"VAULT OK - cooking directory {DIR_ID} took " f"20.00s and succeeded.\n" f"| 'total_time' = 20.00s\n" ) assert result.exit_code == 0, result.output def test_vault_failure(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_failed) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ], catch_exceptions=True, ) assert result.output == ( f"VAULT CRITICAL - cooking directory {DIR_ID} took " f"10.00s and failed with: foobar\n" f"| 'total_time' = 10.00s\n" ) assert result.exit_code == 2, result.output def test_vault_unknown_status(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_unknown_status) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ], catch_exceptions=True, ) assert result.output == ( f"VAULT CRITICAL - cooking directory {DIR_ID} took " f"10.00s and resulted in unknown status: boo\n" f"| 'total_time' = 10.00s\n" ) assert result.exit_code == 2, result.output def test_vault_timeout(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_pending) scenario.add_step( "get", url_api, response_pending, callback=lambda: time.sleep(4000) ) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ], catch_exceptions=True, ) assert result.output == ( f"VAULT CRITICAL - cooking directory {DIR_ID} took more than " f"4020.00s and has status: foo\n" f"| 'total_time' = 4020.00s\n" ) assert result.exit_code == 2, result.output def test_vault_cached_directory(requests_mock, mocker, mocked_time): """First serves a directory that's already in the cache, to test that vault_check requests another one.""" scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=200) scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_done) - scenario.add_step("get", url_fetch, "xx" * 40) + scenario.add_step( + "get", url_fetch, "xx" * 40, headers={"Content-Type": "application/gzip"} + ) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ] ) assert result.output == ( f"VAULT OK - cooking directory {DIR_ID} took " f"10.00s and succeeded.\n" f"| 'total_time' = 10.00s\n" ) assert result.exit_code == 0, result.output def test_vault_no_directory(requests_mock, mocker, mocked_time): """Tests with an empty storage""" scenario = WebScenario() scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage mocker.patch(f"{__name__}.FakeStorage.directory_get_random", return_value=None) result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ], catch_exceptions=True, ) assert result.output == ("VAULT CRITICAL - No directory exists in the archive.\n") assert result.exit_code == 2, result.output -def test_vault_immediate_success_but_fetch_failed(requests_mock, mocker, mocked_time): +def test_vault_fetch_failed(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_done) - scenario.add_step("get", url_fetch, "", status_code=500) + scenario.add_step( + "get", + url_fetch, + "", + status_code=500, + headers={"Content-Type": "application/gzip"}, + ) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ], catch_exceptions=True, ) assert result.output == ( f"VAULT CRITICAL - cooking directory {DIR_ID} took " f"10.00s and succeeded, but fetch failed with status code 500.\n" f"| 'total_time' = 10.00s\n" ) assert result.exit_code == 2, result.output -def test_vault_immediate_success_but_fetch_empty(requests_mock, mocker, mocked_time): +def test_vault_fetch_empty(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_done) - scenario.add_step("get", url_fetch, "") + scenario.add_step( + "get", url_fetch, "", headers={"Content-Type": "application/gzip"} + ) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ], catch_exceptions=True, ) assert result.output == ( f"VAULT CRITICAL - cooking directory {DIR_ID} took " f"10.00s and succeeded, but fetch was empty.\n" f"| 'total_time' = 10.00s\n" ) assert result.exit_code == 2, result.output -def test_vault_immediate_success_but_no_fetch_url(requests_mock, mocker, mocked_time): +def test_vault_fetch_missing_content_type(requests_mock, mocker, mocked_time): + scenario = WebScenario() + + scenario.add_step("get", url_api, {}, status_code=404) + scenario.add_step("post", url_api, response_pending) + scenario.add_step("get", url_api, response_done) + scenario.add_step("get", url_fetch, "") + + scenario.install_mock(requests_mock) + + get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") + get_storage_mock.side_effect = FakeStorage + + result = invoke( + [ + "check-vault", + "--swh-web-url", + "mock://swh-web.example.org", + "--swh-storage-url", + "foo://example.org", + "directory", + ], + catch_exceptions=True, + ) + + assert result.output == ( + "VAULT CRITICAL - Unexpected Content-Type when downloading bundle: None\n" + "| 'total_time' = 10.00s\n" + ) + assert result.exit_code == 2, result.output + + +def test_vault_no_fetch_url(requests_mock, mocker, mocked_time): scenario = WebScenario() scenario.add_step("get", url_api, {}, status_code=404) scenario.add_step("post", url_api, response_pending) scenario.add_step("get", url_api, response_done_no_fetch) scenario.install_mock(requests_mock) get_storage_mock = mocker.patch("swh.icinga_plugins.vault.get_storage") get_storage_mock.side_effect = FakeStorage result = invoke( [ "check-vault", "--swh-web-url", "mock://swh-web.example.org", "--swh-storage-url", "foo://example.org", "directory", ], catch_exceptions=True, ) assert result.output == ( f"VAULT CRITICAL - cooking directory {DIR_ID} took 10.00s and succeeded, " f"but API response did not contain a fetch_url.\n" f"| 'total_time' = 10.00s\n" ) assert result.exit_code == 2, result.output diff --git a/swh/icinga_plugins/tests/web_scenario.py b/swh/icinga_plugins/tests/web_scenario.py index e4225cb..18a7e90 100644 --- a/swh/icinga_plugins/tests/web_scenario.py +++ b/swh/icinga_plugins/tests/web_scenario.py @@ -1,92 +1,94 @@ -# Copyright (C) 2019 The Software Heritage developers +# Copyright (C) 2019-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information """Wrapper around requests-mock to mock successive responses from a web service. Tests can build successive steps by calling :py:meth:`WebScenario.add_step` with specifications of what endpoints should be called and in what order.""" -from dataclasses import dataclass +import dataclasses import json -from typing import Callable, List, Optional, Set +from typing import Callable, Dict, List, Optional, Set, Union import requests_mock -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Step: expected_method: str expected_url: str - response: object + response: Union[str, bytes, Dict, List] status_code: int = 200 + headers: Dict[str, str] = dataclasses.field(default_factory=dict) callback: Optional[Callable[[], int]] = None -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Endpoint: method: str url: str class WebScenario: """Stores the state of the successive calls to the web service expected by tests.""" _steps: List[Step] _endpoints: Set[Endpoint] _current_step: int def __init__(self): self._steps = [] self._endpoints = set() self._current_step = 0 def add_endpoint(self, *args, **kwargs): """Adds an endpoint to be mocked. Arguments are the same as :py:class:Endpoint. """ self._endpoints.add(Endpoint(*args, **kwargs)) def add_step(self, *args, **kwargs): """Adds an expected call to the list of expected calls. Also automatically calls :py:meth:`add_endpoint` so the associated endpoint is mocked. Arguments are the same as :py:class:`Step`. """ step = Step(*args, **kwargs) self._steps.append(step) self.add_endpoint(step.expected_method, step.expected_url) def install_mock(self, mocker: requests_mock.Mocker): """Mocks entrypoints registered with :py:meth:`add_endpoint` (or :py:meth:`add_step`) using the provided mocker. """ for endpoint in self._endpoints: mocker.register_uri( - endpoint.method.upper(), - endpoint.url, - text=self._request_callback, # type: ignore # stubs are too strict + endpoint.method.upper(), endpoint.url, content=self._request_callback, ) def _request_callback(self, request, context): step = self._steps[self._current_step] assert request.url == step.expected_url assert request.method.upper() == step.expected_method.upper() self._current_step += 1 context.status_code = step.status_code + context.headers.update(step.headers) if step.callback: step.callback() if isinstance(step.response, str): + return step.response.encode() + elif isinstance(step.response, bytes): return step.response else: - return json.dumps(step.response) + return json.dumps(step.response).encode() diff --git a/swh/icinga_plugins/vault.py b/swh/icinga_plugins/vault.py index a2e9a39..fb1c1ad 100644 --- a/swh/icinga_plugins/vault.py +++ b/swh/icinga_plugins/vault.py @@ -1,136 +1,145 @@ # Copyright (C) 2019 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import time import requests from swh.storage import get_storage from .base_check import BaseCheck class NoDirectory(Exception): pass class VaultCheck(BaseCheck): TYPE = "VAULT" DEFAULT_WARNING_THRESHOLD = 0 DEFAULT_CRITICAL_THRESHOLD = 3600 def __init__(self, obj): super().__init__(obj) self._swh_storage = get_storage("remote", url=obj["swh_storage_url"]) self._swh_web_url = obj["swh_web_url"] self._poll_interval = obj["poll_interval"] def _url_for_dir(self, dir_id): return self._swh_web_url + f"/api/1/vault/directory/{dir_id.hex()}/" def _pick_directory(self): dir_ = self._swh_storage.directory_get_random() if dir_ is None: raise NoDirectory() return dir_ def _pick_uncached_directory(self): while True: dir_id = self._pick_directory() response = requests.get(self._url_for_dir(dir_id)) if response.status_code == 404: return dir_id def main(self): try: dir_id = self._pick_uncached_directory() except NoDirectory: self.print_result("CRITICAL", "No directory exists in the archive.") return 2 start_time = time.time() total_time = 0 response = requests.post(self._url_for_dir(dir_id)) assert response.status_code == 200, (response, response.text) result = response.json() while result["status"] in ("new", "pending"): time.sleep(self._poll_interval) response = requests.get(self._url_for_dir(dir_id)) assert response.status_code == 200, (response, response.text) result = response.json() total_time = time.time() - start_time if total_time > self.critical_threshold: self.print_result( "CRITICAL", f"cooking directory {dir_id.hex()} took more than " f"{total_time:.2f}s and has status: " f'{result["progress_message"]}', total_time=total_time, ) return 2 if result["status"] == "failed": self.print_result( "CRITICAL", f"cooking directory {dir_id.hex()} took {total_time:.2f}s " f'and failed with: {result["progress_message"]}', total_time=total_time, ) return 2 elif result["status"] != "done": self.print_result( "CRITICAL", f"cooking directory {dir_id.hex()} took {total_time:.2f}s " f'and resulted in unknown status: {result["status"]}', total_time=total_time, ) return 2 (status_code, status) = self.get_status(total_time) if "fetch_url" not in result: self.print_result( "CRITICAL", f"cooking directory {dir_id.hex()} took {total_time:.2f}s " f"and succeeded, but API response did not contain a fetch_url.", total_time=total_time, ) return 2 with requests.get(result["fetch_url"], stream=True) as fetch_response: try: fetch_response.raise_for_status() except requests.HTTPError: self.print_result( "CRITICAL", f"cooking directory {dir_id.hex()} took {total_time:.2f}s " f"and succeeded, but fetch failed with status code " f"{fetch_response.status_code}.", total_time=total_time, ) return 2 + content_type = fetch_response.headers.get("Content-Type") + if content_type != "application/gzip": + self.print_result( + "CRITICAL", + f"Unexpected Content-Type when downloading bundle: {content_type}", + total_time=total_time, + ) + return 2 + response_length = 0 for chunk in fetch_response.iter_content(decode_unicode=False): response_length += len(chunk) if response_length == 0: self.print_result( "CRITICAL", f"cooking directory {dir_id.hex()} took {total_time:.2f}s " f"and succeeded, but fetch was empty.", total_time=total_time, ) return 2 self.print_result( status, f"cooking directory {dir_id.hex()} took {total_time:.2f}s " f"and succeeded.", total_time=total_time, ) return status_code