diff --git a/swh/icinga_plugins/cli.py b/swh/icinga_plugins/cli.py index 46017c3..a98906f 100644 --- a/swh/icinga_plugins/cli.py +++ b/swh/icinga_plugins/cli.py @@ -1,104 +1,135 @@ # Copyright (C) 2019-2020 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 # WARNING: do not import unnecessary things here to keep cli startup time under # control import sys import click from swh.core.cli import CONTEXT_SETTINGS from swh.core.cli import swh as swh_cli_group @swh_cli_group.group(name="icinga_plugins", context_settings=CONTEXT_SETTINGS) @click.option("-w", "--warning", type=int, help="Warning threshold.") @click.option("-c", "--critical", type=int, help="Critical threshold.") @click.pass_context def icinga_cli_group(ctx, warning, critical): """Main command for Icinga plugins """ ctx.ensure_object(dict) if warning: ctx.obj["warning_threshold"] = int(warning) if critical: ctx.obj["critical_threshold"] = int(critical) @icinga_cli_group.group(name="check-vault") @click.option( "--swh-storage-url", type=str, required=True, help="URL to an swh-storage HTTP API" ) @click.option( "--swh-web-url", type=str, required=True, help="URL to an swh-web instance" ) @click.option( "--poll-interval", type=int, default=10, help="Interval (in seconds) between two polls to the API, " "to check for cooking status.", ) @click.pass_context def check_vault(ctx, **kwargs): ctx.obj.update(kwargs) @check_vault.command(name="directory") @click.pass_context def check_vault_directory(ctx): """Picks a random directory, requests its cooking via swh-web, and waits for completion.""" from .vault import VaultCheck sys.exit(VaultCheck(ctx.obj).main()) +@icinga_cli_group.group(name="check-savecodenow") +@click.option( + "--swh-web-url", type=str, required=True, help="URL to an swh-web instance" +) +@click.option( + "--poll-interval", + type=int, + default=10, + help="Interval (in seconds) between two polls to the API, " + "to check for save code now status.", +) +@click.pass_context +def check_scn(ctx, **kwargs): + ctx.obj.update(kwargs) + + +@check_scn.command(name="origin") +@click.argument("origin", type=str) +@click.option("--visit-type", type=str, required=True, help="Visit type for origin") +@click.pass_context +def check_scn_origin(ctx, origin, visit_type): + """Requests a save code now via the api for a given origin with type visit_type, waits + for its completion, report approximate time of completion (failed or succeeded) and + warn if threshold exceeded. + + """ + from .save_code_now import SaveCodeNowCheck + + sys.exit(SaveCodeNowCheck(ctx.obj, origin, visit_type).main()) + + @icinga_cli_group.group(name="check-deposit") @click.option( "--server", type=str, default="https://deposit.softwareheritage.org/1", help="URL to the SWORD server to test", ) @click.option("--username", type=str, required=True, help="Login for the SWORD server") @click.option( "--password", type=str, required=True, help="Password for the SWORD server" ) @click.option( "--collection", type=str, required=True, help="Software collection to use on the SWORD server", ) @click.option( "--poll-interval", type=int, default=10, help="Interval (in seconds) between two polls to the API, " "to check for ingestion status.", ) @click.pass_context def check_deposit(ctx, **kwargs): ctx.obj.update(kwargs) @check_deposit.command(name="single") @click.option( "--archive", type=click.Path(), required=True, help="Software artefact to upload" ) @click.option( "--metadata", type=click.Path(), required=True, help="Metadata file for the software artefact.", ) @click.pass_context def check_deposit_single(ctx, **kwargs): """Checks the provided archive and metadata file and be deposited.""" from .deposit import DepositCheck ctx.obj.update(kwargs) sys.exit(DepositCheck(ctx.obj).main()) diff --git a/swh/icinga_plugins/save_code_now.py b/swh/icinga_plugins/save_code_now.py new file mode 100644 index 0000000..02ed4fa --- /dev/null +++ b/swh/icinga_plugins/save_code_now.py @@ -0,0 +1,113 @@ +# Copyright (C) 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 typing import Dict, List + +import requests + +from .base_check import BaseCheck + +REPORT_MSG = "Save code now request for origin" + +WAITING_STATUSES = ("not yet scheduled", "running", "scheduled") + + +class SaveCodeNowCheck(BaseCheck): + TYPE = "SAVECODENOW" + DEFAULT_WARNING_THRESHOLD = 60 + DEFAULT_CRITICAL_THRESHOLD = 120 + + def __init__(self, obj: Dict, origin: str, visit_type: str) -> None: + super().__init__(obj) + self.api_url = obj["swh_web_url"].rstrip("/") + self.poll_interval = obj["poll_interval"] + self.origin = origin + self.visit_type = visit_type + + @staticmethod + def api_url_scn(root_api_url: str, origin: str, visit_type: str) -> str: + """Compute the save code now api url for a given origin""" + return f"{root_api_url}/api/1/origin/save/{visit_type}/url/{origin}/" + + def main(self) -> int: + """Scenario description: + + 1. Requests a save code now request via the api for origin self.origin with type + self.visit_type. + + 2. Polling regularly at self.poll_interval seconds the completion status. + + 3. When either succeeded, failed or threshold exceeded, report approximate time + of completion. This will warn if thresholds are exceeded. + + """ + start_time: float = time.time() + total_time: float = 0.0 + scn_url = self.api_url_scn(self.api_url, self.origin, self.visit_type) + response = requests.post(scn_url) + assert response.status_code == 200, (response, response.text) + + result: Dict = response.json() + + status_key = "save_task_status" + request_date = result["save_request_date"] + origin_info = (self.visit_type, self.origin) + + while result[status_key] in WAITING_STATUSES: + time.sleep(self.poll_interval) + response = requests.get(scn_url) + assert ( + response.status_code == 200 + ), "Unexpected response: {response}, {response.text}" + raw_result: List[Dict] = response.json() + assert len(raw_result) > 0, f"Unexpected result: {raw_result}" + + if len(raw_result) > 1: + # retrieve only the one status result we are interested in + result = next( + filter(lambda r: r["save_request_date"] == request_date, raw_result) + ) + else: + result = raw_result[0] + + # this because the api can return multiple entries for the same origin + assert result["save_request_date"] == request_date + + total_time = time.time() - start_time + + if total_time > self.critical_threshold: + self.print_result( + "CRITICAL", + f"{REPORT_MSG} {origin_info} took more than {total_time:.2f}s " + f'and has status: {result["save_task_status"]}.', + total_time=total_time, + ) + return 2 + + if result[status_key] == "succeeded": + (status_code, status) = self.get_status(total_time) + self.print_result( + status, + f"{REPORT_MSG} {origin_info} took {total_time:.2f}s and succeeded.", + total_time=total_time, + ) + return status_code + elif result[status_key] == "failed": + self.print_result( + "CRITICAL", + f"{REPORT_MSG} {origin_info} took {total_time:.2f}s and failed.", + total_time=total_time, + ) + return 2 + else: + self.print_result( + "CRITICAL", + f"{REPORT_MSG} {origin_info} took {total_time:.2f}s " + "and resulted in unsupported status: " + f"{result['save_request_status']} ; {result[status_key]}.", + total_time=total_time, + ) + return 2 diff --git a/swh/icinga_plugins/tests/test_deposit.py b/swh/icinga_plugins/tests/test_deposit.py index cc8e946..a3bed1a 100644 --- a/swh/icinga_plugins/tests/test_deposit.py +++ b/swh/icinga_plugins/tests/test_deposit.py @@ -1,628 +1,618 @@ -# Copyright (C) 2019-2020 The Software Heritage developers +# 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 io import os import tarfile import time from typing import Optional -from click.testing import CliRunner import pytest -from swh.icinga_plugins.cli import icinga_cli_group +from swh.icinga_plugins.tests.utils import invoke from .web_scenario import WebScenario BASE_URL = "http://swh-deposit.example.org/1" COMMON_OPTIONS = [ "--server", BASE_URL, "--username", "test", "--password", "test", "--collection", "testcol", ] SAMPLE_METADATA = """ Test Software swh test-software No One """ ENTRY_TEMPLATE = """ 42 2019-12-19 18:11:00 foo.tar.gz {status} http://purl.org/net/sword/package/SimpleZip """ STATUS_TEMPLATE = """ 42 {status} {status_detail}%s """ def status_template( status: str, status_detail: str = "", swhid: Optional[str] = None ) -> str: """Generate a proper status template out of status, status_detail and optional swhid """ if swhid is not None: template = ( STATUS_TEMPLATE % f"\n {swhid}" ) return template.format(status=status, status_detail=status_detail, swhid=swhid) template = STATUS_TEMPLATE % "" return template.format(status=status, status_detail=status_detail) def test_status_template(): actual_status = status_template(status="deposited") assert ( actual_status == """ 42 deposited """ ) actual_status = status_template(status="verified", status_detail="detail") assert ( actual_status == """ 42 verified detail """ ) actual_status = status_template(status="done", swhid="10") assert ( actual_status == """ 42 done 10 """ ) @pytest.fixture(scope="session") def tmp_path(tmp_path_factory): return tmp_path_factory.mktemp(__name__) @pytest.fixture(scope="session") def sample_metadata(tmp_path): """Returns a sample metadata file's path """ path = os.path.join(tmp_path, "metadata.xml") with open(path, "w") as fd: fd.write(SAMPLE_METADATA) return path @pytest.fixture(scope="session") def sample_archive(tmp_path): """Returns a sample archive's path """ path = os.path.join(tmp_path, "archive.tar.gz") with tarfile.open(path, "w:gz") as tf: tf.addfile(tarfile.TarInfo("hello.py"), io.BytesIO(b'print("Hello world")')) return path -def invoke(args, catch_exceptions=False): - runner = CliRunner() - result = runner.invoke(icinga_cli_group, args) - if not catch_exceptions and result.exception: - print(result.output) - raise result.exception - return result - - def test_deposit_immediate_success( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): """Both deposit creation and deposit metadata update passed without delays """ scenario = WebScenario() status_xml = status_template( status="done", status_detail="", swhid="swh:1:dir:02ed6084fb0e8384ac58980e07548a547431cf74", ) # Initial deposit scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="done") ) # Then metadata update status_xml = status_template( status="done", status_detail="", swhid="swh:1:dir:02ed6084fb0e8384ac58980e07548a547431cf74", ) scenario.add_step("get", f"{BASE_URL}/testcol/42/status/", status_xml) # internal deposit client does call status, then update metadata then status api scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_xml, ) scenario.add_step( "put", f"{BASE_URL}/testcol/42/atom/", status_xml, ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_xml, ) scenario.install_mock(requests_mock) result = invoke( [ "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ] ) assert result.output == ( "DEPOSIT OK - Deposit took 0.00s and succeeded.\n" "| 'load_time' = 0.00s\n" "| 'total_time' = 0.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 0.00s\n" "DEPOSIT OK - Deposit Metadata update took 0.00s and succeeded.\n" "| 'total_time' = 0.00s\n" "| 'update_time' = 0.00s\n" ) assert result.exit_code == 0, f"Unexpected output: {result.output}" def test_deposit_delays( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): """Deposit creation passed with some delays, deposit metadata update passed without delay """ scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited") ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="verified"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="loading"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="done"), ) # Then metadata update status_xml = status_template( status="done", status_detail="", swhid="swh:1:dir:02ed6084fb0e8384ac58980e07548a547431cf74", ) scenario.add_step("get", f"{BASE_URL}/testcol/42/status/", status_xml) # internal deposit client does call status, then update metadata then status api scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_xml, ) scenario.add_step( "put", f"{BASE_URL}/testcol/42/atom/", status_xml, ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_xml, ) scenario.install_mock(requests_mock) result = invoke( [ "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ] ) assert result.output == ( "DEPOSIT OK - Deposit took 30.00s and succeeded.\n" "| 'load_time' = 20.00s\n" "| 'total_time' = 30.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 10.00s\n" "DEPOSIT OK - Deposit Metadata update took 0.00s and succeeded.\n" "| 'total_time' = 30.00s\n" "| 'update_time' = 0.00s\n" ) assert result.exit_code == 0, f"Unexpected output: {result.output}" def test_deposit_then_metadata_update_failed( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): """Deposit creation passed, deposit metadata update failed """ scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited") ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="verified"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="loading"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="done"), ) # Then metadata update calls failed_status_xml = status_template( status="failed", # lying here status_detail="Failure to ingest", swhid="swh:1:dir:02ed6084fb0e8384ac58980e07548a547431cf74", ) scenario.add_step("get", f"{BASE_URL}/testcol/42/status/", failed_status_xml) scenario.add_step("get", f"{BASE_URL}/testcol/42/status/", failed_status_xml) scenario.install_mock(requests_mock) result = invoke( [ "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ], catch_exceptions=True, ) assert result.output == ( "DEPOSIT OK - Deposit took 30.00s and succeeded.\n" "| 'load_time' = 20.00s\n" "| 'total_time' = 30.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 10.00s\n" "DEPOSIT CRITICAL - Deposit Metadata update failed: You can only update " "metadata on deposit with status 'done' \n" "| 'total_time' = 30.00s\n" "| 'update_time' = 0.00s\n" ) assert result.exit_code == 2, f"Unexpected output: {result.output}" def test_deposit_delay_warning( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): """Deposit creation exceeded delays, no deposit update occurred. """ scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited") ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="verified"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="done"), ) scenario.install_mock(requests_mock) result = invoke( [ "--warning", "15", "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ], catch_exceptions=True, ) assert result.output == ( "DEPOSIT WARNING - Deposit took 20.00s and succeeded.\n" "| 'load_time' = 10.00s\n" "| 'total_time' = 20.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 10.00s\n" ) assert result.exit_code == 1, f"Unexpected output: {result.output}" def test_deposit_delay_critical( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited") ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="verified"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="done"), callback=lambda: time.sleep(60), ) scenario.install_mock(requests_mock) result = invoke( [ "--critical", "50", "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ], catch_exceptions=True, ) assert result.output == ( "DEPOSIT CRITICAL - Deposit took 80.00s and succeeded.\n" "| 'load_time' = 70.00s\n" "| 'total_time' = 80.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 10.00s\n" ) assert result.exit_code == 2, f"Unexpected output: {result.output}" def test_deposit_timeout( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited"), callback=lambda: time.sleep(1500), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="verified"), callback=lambda: time.sleep(1500), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="loading"), callback=lambda: time.sleep(1500), ) scenario.install_mock(requests_mock) result = invoke( [ "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ], catch_exceptions=True, ) assert result.output == ( "DEPOSIT CRITICAL - Timed out while in status loading " "(4520.0s seconds since deposit started)\n" "| 'total_time' = 4520.00s\n" "| 'upload_time' = 1500.00s\n" "| 'validation_time' = 1510.00s\n" ) assert result.exit_code == 2, f"Unexpected output: {result.output}" def test_deposit_rejected( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited") ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="rejected", status_detail="booo"), ) scenario.install_mock(requests_mock) result = invoke( [ "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ], catch_exceptions=True, ) assert result.output == ( "DEPOSIT CRITICAL - Deposit was rejected: booo\n" "| 'total_time' = 10.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 10.00s\n" ) assert result.exit_code == 2, f"Unexpected output: {result.output}" def test_deposit_failed( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited") ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="verified"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="loading"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="failed", status_detail="booo"), ) scenario.install_mock(requests_mock) result = invoke( [ "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ], catch_exceptions=True, ) assert result.output == ( "DEPOSIT CRITICAL - Deposit loading failed: booo\n" "| 'load_time' = 20.00s\n" "| 'total_time' = 30.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 10.00s\n" ) assert result.exit_code == 2, f"Unexpected output: {result.output}" def test_deposit_unexpected_status( requests_mock, mocker, sample_archive, sample_metadata, mocked_time ): scenario = WebScenario() scenario.add_step( "post", f"{BASE_URL}/testcol/", ENTRY_TEMPLATE.format(status="deposited") ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="verified"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="loading"), ) scenario.add_step( "get", f"{BASE_URL}/testcol/42/status/", status_template(status="what", status_detail="booo"), ) scenario.install_mock(requests_mock) result = invoke( [ "check-deposit", *COMMON_OPTIONS, "single", "--archive", sample_archive, "--metadata", sample_metadata, ], catch_exceptions=True, ) assert result.output == ( "DEPOSIT CRITICAL - Deposit got unexpected status: what (booo)\n" "| 'load_time' = 20.00s\n" "| 'total_time' = 30.00s\n" "| 'upload_time' = 0.00s\n" "| 'validation_time' = 10.00s\n" ) assert result.exit_code == 2, f"Unexpected output: {result.output}" diff --git a/swh/icinga_plugins/tests/test_save_code_now.py b/swh/icinga_plugins/tests/test_save_code_now.py new file mode 100644 index 0000000..bd5e95a --- /dev/null +++ b/swh/icinga_plugins/tests/test_save_code_now.py @@ -0,0 +1,258 @@ +# Copyright (C) 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 + +from datetime import datetime, timezone +import random +from typing import Dict, Optional, Tuple + +import pytest + +from swh.icinga_plugins.save_code_now import ( + REPORT_MSG, + WAITING_STATUSES, + SaveCodeNowCheck, +) + +from .utils import invoke +from .web_scenario import WebScenario + + +def fake_response( + origin: str, + visit_type: str, + sor_status: str = "pending", + task_status: Optional[str] = None, +) -> Dict: + """Fake a save code now request api response""" + visit_date = None + if task_status in ("failed", "succeeded"): + visit_date = str(datetime.now(tz=timezone.utc)) + + return { + "visit_type": visit_type, + "origin_url": origin, + "save_request_date": "to-replace", + "save_request_status": sor_status, + "save_task_status": task_status, + "visit_date": visit_date, + } + + +@pytest.fixture +def origin_info() -> Tuple[str, str]: + """Build an origin info to request save code now + + """ + origin_name = random.choice(range(10)) + return random.choice(["git", "svn", "hg"]), f"mock://fake-origin-url/{origin_name}" + + +def test_save_code_now_success(requests_mock, mocker, mocked_time, origin_info): + """Successful ingestion scenario below threshold""" + scenario = WebScenario() + visit_type, origin = origin_info + + root_api_url = "mock://swh-web.example.org" + api_url = SaveCodeNowCheck.api_url_scn(root_api_url, origin, visit_type) + + # creation request + scenario.add_step( + "post", + api_url, + fake_response(origin, visit_type, "accepted", "not yet scheduled"), + ) + response_scheduled = fake_response(origin, visit_type, "accepted", "scheduled") + # status polling requests + scenario.add_step("get", api_url, [response_scheduled]) + # sometimes we can have multiple response so we fake that here + scenario.add_step("get", api_url, [response_scheduled, response_scheduled]) + scenario.add_step( + "get", api_url, [fake_response(origin, visit_type, "accepted", "succeeded")] + ) + scenario.install_mock(requests_mock) + + # fmt: off + result = invoke( + [ + "check-savecodenow", "--swh-web-url", root_api_url, + "origin", origin, + "--visit-type", visit_type, + ] + ) + # fmt: on + + assert result.output == ( + f"{SaveCodeNowCheck.TYPE} OK - {REPORT_MSG} {origin_info} took " + f"30.00s and succeeded.\n" + f"| 'total_time' = 30.00s\n" + ) + assert result.exit_code == 0, f"Unexpected result: {result.output}" + + +def test_save_code_now_failure(requests_mock, mocker, mocked_time, origin_info): + """Failed ingestion scenario should be reported""" + scenario = WebScenario() + visit_type, origin = origin_info + + root_api_url = "mock://swh-web.example.org" + api_url = SaveCodeNowCheck.api_url_scn(root_api_url, origin, visit_type) + + # creation request + scenario.add_step( + "post", + api_url, + fake_response(origin, visit_type, "accepted", "not yet scheduled"), + ) + # status polling requests + scenario.add_step( + "get", api_url, [fake_response(origin, visit_type, "accepted", "scheduled")] + ) + scenario.add_step( + "get", api_url, [fake_response(origin, visit_type, "accepted", "failed")] + ) + scenario.install_mock(requests_mock) + + # fmt: off + result = invoke( + [ + "check-savecodenow", "--swh-web-url", root_api_url, + "origin", origin, + "--visit-type", visit_type, + ], + catch_exceptions=True, + ) + # fmt: on + + assert result.output == ( + f"{SaveCodeNowCheck.TYPE} CRITICAL - {REPORT_MSG} {origin_info} took " + f"20.00s and failed.\n" + f"| 'total_time' = 20.00s\n" + ) + assert result.exit_code == 2, f"Unexpected result: {result.output}" + + +def test_save_code_now_pending_state_unsupported( + requests_mock, mocker, mocked_time, origin_info +): + """Pending save requests are not supported in the test so they should fail early + + Pending requests are requests that need a moderator to accept the repository into + the save code now flow. + + Do not actually use such origin to trigger the checks. + + """ + scenario = WebScenario() + visit_type, origin = origin_info + root_api_url = "mock://swh-web2.example.org" + api_url = SaveCodeNowCheck.api_url_scn(root_api_url, origin, visit_type) + + # creation request + scenario.add_step( + "post", api_url, fake_response(origin, visit_type, "pending", "not created"), + ) + scenario.install_mock(requests_mock) + + # fmt: off + result = invoke( + [ + "check-savecodenow", "--swh-web-url", root_api_url, + "origin", origin, + "--visit-type", visit_type, + ], + catch_exceptions=True, + ) + # fmt: on + + assert result.output == ( + f"{SaveCodeNowCheck.TYPE} CRITICAL - {REPORT_MSG} {origin_info} took " + f"0.00s and resulted in unsupported status: pending ; not created.\n" + f"| 'total_time' = 0.00s\n" + ) + assert result.exit_code == 2, f"Unexpected output: {result.output}" + + +def test_save_code_now_threshold_exceeded( + requests_mock, mocker, mocked_time, origin_info +): + """Saving requests exceeding threshold should mention warning in output + + """ + scenario = WebScenario() + visit_type, origin = origin_info + + root_api_url = "mock://swh-web2.example.org" + api_url = SaveCodeNowCheck.api_url_scn(root_api_url, origin, visit_type) + + # creation request + scenario.add_step( + "post", + api_url, + fake_response(origin, visit_type, "accepted", "not yet scheduled"), + ) + + # we'll make the response being in the awaiting status + # beyond 13, this will exceed the threshold + for i in range(13): + waiting_status = random.choice(WAITING_STATUSES) + response_scheduled = fake_response( + origin, visit_type, "accepted", waiting_status + ) + scenario.add_step("get", api_url, [response_scheduled]) + scenario.install_mock(requests_mock) + + # fmt: off + result = invoke( + [ + "check-savecodenow", "--swh-web-url", root_api_url, + "origin", origin, + "--visit-type", visit_type, + ], + catch_exceptions=True, + ) + # fmt: on + + assert result.output == ( + f"{SaveCodeNowCheck.TYPE} CRITICAL - {REPORT_MSG} {origin_info} took " + f"more than 130.00s and has status: {waiting_status}.\n" + f"| 'total_time' = 130.00s\n" + ) + assert result.exit_code == 2, f"Unexpected output: {result.output}" + + +def test_save_code_now_unexpected_failure( + requests_mock, mocker, mocked_time, origin_info +): + """Unexpected failure if the webapi refuses to answer for example""" + scenario = WebScenario() + visit_type, origin = origin_info + + root_api_url = "mock://swh-web.example.org" + api_url = SaveCodeNowCheck.api_url_scn(root_api_url, origin, visit_type) + + # creation request + scenario.add_step( + "post", + api_url, + fake_response(origin, visit_type, "accepted", "not yet scheduled"), + ) + # status polling requests + scenario.add_step( + "get", api_url, [fake_response(origin, visit_type, "accepted", "scheduled")] + ) + # unexpected issue when communicating with the api + scenario.add_step("get", api_url, {}, status_code=500) + scenario.install_mock(requests_mock) + + with pytest.raises(AssertionError): + # fmt: off + invoke( + [ + "check-savecodenow", "--swh-web-url", root_api_url, + "origin", origin, + "--visit-type", visit_type, + ], + ) + # fmt: on diff --git a/swh/icinga_plugins/tests/test_vault.py b/swh/icinga_plugins/tests/test_vault.py index 787612a..bf578b8 100644 --- a/swh/icinga_plugins/tests/test_vault.py +++ b/swh/icinga_plugins/tests/test_vault.py @@ -1,291 +1,280 @@ -# Copyright (C) 2019 The Software Heritage developers +# 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 click.testing import CliRunner - -from swh.icinga_plugins.cli import icinga_cli_group +from swh.icinga_plugins.tests.utils import invoke from .web_scenario import WebScenario dir_id = "ab" * 20 response_pending = { "obj_id": dir_id, "obj_type": "directory", "progress_message": "foo", "status": "pending", } response_done = { "fetch_url": f"/api/1/vault/directory/{dir_id}/raw/", "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 invoke(args, catch_exceptions=False): - runner = CliRunner() - result = runner.invoke(icinga_cli_group, args) - if not catch_exceptions and result.exception: - print(result.output) - raise result.exception - return result - - def test_vault_immediate_success(requests_mock, mocker, mocked_time): scenario = WebScenario() url = f"mock://swh-web.example.org/api/1/vault/directory/{dir_id}/" scenario.add_step("get", url, {}, status_code=404) scenario.add_step("post", url, response_pending) scenario.add_step("get", url, response_done) 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() url = f"mock://swh-web.example.org/api/1/vault/directory/{dir_id}/" scenario.add_step("get", url, {}, status_code=404) scenario.add_step("post", url, response_pending) scenario.add_step("get", url, response_pending) scenario.add_step("get", url, response_done) 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() url = f"mock://swh-web.example.org/api/1/vault/directory/{dir_id}/" scenario.add_step("get", url, {}, status_code=404) scenario.add_step("post", url, response_pending) scenario.add_step("get", url, 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() url = f"mock://swh-web.example.org/api/1/vault/directory/{dir_id}/" scenario.add_step("get", url, {}, status_code=404) scenario.add_step("post", url, response_pending) scenario.add_step("get", url, 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() url = f"mock://swh-web.example.org/api/1/vault/directory/{dir_id}/" scenario.add_step("get", url, {}, status_code=404) scenario.add_step("post", url, response_pending) scenario.add_step("get", url, response_pending) scenario.add_step("get", url, 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() url = f"mock://swh-web.example.org/api/1/vault/directory/{dir_id}/" scenario.add_step("get", url, {}, status_code=200) scenario.add_step("get", url, {}, status_code=404) scenario.add_step("post", url, response_pending) scenario.add_step("get", url, response_done) 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 diff --git a/swh/icinga_plugins/tests/utils.py b/swh/icinga_plugins/tests/utils.py new file mode 100644 index 0000000..cf6b9af --- /dev/null +++ b/swh/icinga_plugins/tests/utils.py @@ -0,0 +1,20 @@ +# 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 + +from typing import List + +from click.testing import CliRunner, Result + +from swh.icinga_plugins.cli import icinga_cli_group + + +def invoke(args: List[str], catch_exceptions: bool = False) -> Result: + """Invoke icinga plugin main cli command with args""" + runner = CliRunner() + result = runner.invoke(icinga_cli_group, args) + if not catch_exceptions and result.exception: + print(result.output) + raise result.exception + return result