diff --git a/requirements-swh.txt b/requirements-swh.txt --- a/requirements-swh.txt +++ b/requirements-swh.txt @@ -1 +1 @@ -# Add here internal Software Heritage dependencies, one per line. +swh.storage > v0.0.161 diff --git a/requirements-test.txt b/requirements-test.txt --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1 +1,2 @@ pytest +pytest-mock diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -# Add here external Python modules dependencies, one per line. Module names -# should match https://pypi.python.org/pypi names. For the full spec or -# dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html +psycopg2 +requests vcversioner diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -51,8 +51,8 @@ vcversioner={}, include_package_data=True, entry_points=''' - [console_scripts] - check_vault = swh.icinga_plugins.vault:main + [swh.cli.subcommands] + icinga_plugins=swh.icinga_plugins.cli:cli ''', classifiers=[ "Programming Language :: Python :: 3", diff --git a/swh/icinga_plugins/bar.py b/swh/icinga_plugins/bar.py deleted file mode 100644 --- a/swh/icinga_plugins/bar.py +++ /dev/null @@ -1,4 +0,0 @@ -# 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 diff --git a/swh/icinga_plugins/cli.py b/swh/icinga_plugins/cli.py --- a/swh/icinga_plugins/cli.py +++ b/swh/icinga_plugins/cli.py @@ -1,18 +1,43 @@ +# 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 sys + import click from swh.core.cli import CONTEXT_SETTINGS +from .vault import VaultCheck + -@click.group(name='foo', context_settings=CONTEXT_SETTINGS) +@click.group(name='icinga_plugins', context_settings=CONTEXT_SETTINGS) +@click.option('--swh-storage-url', type=str, + help='URL to an swh-storage HTTP API') +@click.option('--swh-web-url', type=str, + help='URL to an swh-web instance') @click.pass_context -def cli(ctx): - """Foo main command. +def cli(ctx, swh_storage_url, swh_web_url): + """Main command for Icinga plugins """ + ctx.ensure_object(dict) + ctx.obj['swh_storage_url'] = swh_storage_url + ctx.obj['swh_web_url'] = swh_web_url + + +@cli.group(name='check_vault') +@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, poll_interval): + ctx.obj['poll_interval'] = poll_interval -@cli.command() -@click.option('--bar', help='Something') +@check_vault.command(name='directory') @click.pass_context -def bar(ctx, bar): - '''Do something.''' - click.echo('bar') +def check_vault_directory(ctx): + """Picks a random directory, requests its cooking via swh-web, + and waits for completion.""" + sys.exit(VaultCheck(ctx.obj).main()) diff --git a/swh/icinga_plugins/tests/test_vault.py b/swh/icinga_plugins/tests/test_vault.py new file mode 100644 --- /dev/null +++ b/swh/icinga_plugins/tests/test_vault.py @@ -0,0 +1,235 @@ +# 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 enum +import json +import re + +from click.testing import CliRunner +import pytest + +from swh.icinga_plugins.cli import cli + + +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" +} + +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(cli, args) + if not catch_exceptions and result.exception: + print(result.output) + raise result.exception + return result + + +def test_vault_immediate_success(requests_mock, mocker): + + class Step(enum.Enum): + NOTHING_DONE = 0 + CHECKED_UNCOOKED = 1 + REQUESTED_COOKING = 2 + + step = Step.NOTHING_DONE + + def post_callback(request, context): + nonlocal step + if step == Step.CHECKED_UNCOOKED: + step = Step.REQUESTED_COOKING + return json.dumps(response_pending) + else: + assert False, step + + def get_callback(request, context): + context.json = True + nonlocal step + if step == Step.NOTHING_DONE: + context.status_code = 404 + step = Step.CHECKED_UNCOOKED + elif step == Step.CHECKED_UNCOOKED: + assert False + elif step == Step.REQUESTED_COOKING: + return json.dumps(response_done) + else: + assert False, step + + requests_mock.get( + f'mock://swh-web.example.org/api/1/vault/directory/{dir_id}/', + text=get_callback) + requests_mock.post( + f'mock://swh-web.example.org/api/1/vault/directory/{dir_id}/', + text=post_callback) + + runner = CliRunner() + + get_storage_mock = mocker.patch('swh.icinga_plugins.vault.get_storage') + get_storage_mock.side_effect=FakeStorage + + sleep_mock = mocker.patch('time.sleep') + + result = invoke([ + '--swh-web-url', 'mock://swh-web.example.org', + '--swh-storage-url', 'foo://example.org', + 'check_vault', 'directory', + ]) + + assert re.match( + rf'VAULT OK - cooking directory {dir_id} took ' + r'[0-9]\.[0-9]{2}s and succeeded.\n' + r"| 'total time' = [0-9]\.[0-9]{2}s", + result.output) + + sleep_mock.assert_called_once_with(10) + + +def test_vault_delayed_success(requests_mock, mocker): + + class Step(enum.Enum): + NOTHING_DONE = 0 + CHECKED_UNCOOKED = 1 + REQUESTED_COOKING = 2 + PENDING = 3 + + step = Step.NOTHING_DONE + + def post_callback(request, context): + nonlocal step + if step == Step.CHECKED_UNCOOKED: + step = Step.REQUESTED_COOKING + return json.dumps(response_pending) + else: + assert False, step + + def get_callback(request, context): + context.json = True + nonlocal step + if step == Step.NOTHING_DONE: + context.status_code = 404 + step = Step.CHECKED_UNCOOKED + elif step == Step.CHECKED_UNCOOKED: + assert False + elif step == Step.REQUESTED_COOKING: + step = Step.PENDING + return json.dumps(response_pending) + elif step == Step.PENDING: + return json.dumps(response_done) + else: + assert False, step + + requests_mock.get( + f'mock://swh-web.example.org/api/1/vault/directory/{dir_id}/', + text=get_callback) + requests_mock.post( + f'mock://swh-web.example.org/api/1/vault/directory/{dir_id}/', + text=post_callback) + + runner = CliRunner() + + get_storage_mock = mocker.patch('swh.icinga_plugins.vault.get_storage') + get_storage_mock.side_effect=FakeStorage + + sleep_mock = mocker.patch('time.sleep') + + result = invoke([ + '--swh-web-url', 'mock://swh-web.example.org', + '--swh-storage-url', 'foo://example.org', + 'check_vault', 'directory', + ]) + + assert re.match( + rf'VAULT OK - cooking directory {dir_id} took ' + r'[0-9]\.[0-9]{2}s and succeeded.\n' + r"| 'total time' = [0-9]\.[0-9]{2}s", + result.output) + + assert sleep_mock.call_count == 2 + + +def test_vault_failure(requests_mock, mocker): + + class Step(enum.Enum): + NOTHING_DONE = 0 + CHECKED_UNCOOKED = 1 + REQUESTED_COOKING = 2 + + step = Step.NOTHING_DONE + + def post_callback(request, context): + nonlocal step + if step == Step.CHECKED_UNCOOKED: + step = Step.REQUESTED_COOKING + return json.dumps(response_pending) + else: + assert False, step + + def get_callback(request, context): + context.json = True + nonlocal step + if step == Step.NOTHING_DONE: + context.status_code = 404 + step = Step.CHECKED_UNCOOKED + elif step == Step.CHECKED_UNCOOKED: + assert False + elif step == Step.REQUESTED_COOKING: + return json.dumps(response_failed) + else: + assert False, step + + requests_mock.get( + f'mock://swh-web.example.org/api/1/vault/directory/{dir_id}/', + text=get_callback) + requests_mock.post( + f'mock://swh-web.example.org/api/1/vault/directory/{dir_id}/', + text=post_callback) + + runner = CliRunner() + + get_storage_mock = mocker.patch('swh.icinga_plugins.vault.get_storage') + get_storage_mock.side_effect=FakeStorage + + sleep_mock = mocker.patch('time.sleep') + + result = invoke([ + '--swh-web-url', 'mock://swh-web.example.org', + '--swh-storage-url', 'foo://example.org', + 'check_vault', 'directory', + ], catch_exceptions=True) + + assert re.match( + rf'VAULT CRITICAL - cooking directory {dir_id} took ' + r'[0-9]\.[0-9]{2}s and failed with: foobar\n' + r"| 'total time' = [0-9]\.[0-9]{2}s", + result.output) + + sleep_mock.assert_called_once_with(10) diff --git a/swh/icinga_plugins/vault.py b/swh/icinga_plugins/vault.py new file mode 100644 --- /dev/null +++ b/swh/icinga_plugins/vault.py @@ -0,0 +1,74 @@ +# 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 os +import time + +import requests + +from swh.core.db import BaseDb +from swh.storage import get_storage + + +class NoDirectory(Exception): + pass + + +class VaultCheck: + def __init__(self, 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): + return self._swh_storage.directory_get_random() + + 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: + print('VAULT CRITICAL - No directory exists in the archive') + return 2 + + start_time = time.time() + 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() + + end_time = time.time() + total_time = end_time - start_time + + if result['status'] == 'done': + print(f'VAULT OK - cooking directory {dir_id.hex()} ' + f'took {total_time:.2f}s and succeeded.') + print(f"| 'total time' = {total_time:.2f}s") + return 0 + elif result['status'] == 'failed': + print(f'VAULT CRITICAL - cooking directory {dir_id.hex()} ' + f'took {total_time:.2f}s and failed with: ' + f'{result["progress_message"]}') + print(f"| 'total time' = {total_time:.2f}s") + return 3 + else: + print(f'VAULT CRITICAL - cooking directory {dir_id.hex()} ' + f'took {total_time:.2f}s and resulted in unknown: ' + f'status: {result["status"]}') + print(f"| 'total time' = {total_time:.2f}s") + return 3