diff --git a/requirements-swh.txt b/requirements-swh.txt
--- a/requirements-swh.txt
+++ b/requirements-swh.txt
@@ -1 +1,3 @@
+swh.core[http] # required by swh.storage
+swh.deposit
swh.storage >= v0.0.162
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
@@ -9,6 +9,7 @@
from swh.core.cli import CONTEXT_SETTINGS
+from .deposit import DepositCheck
from .vault import VaultCheck
@@ -44,3 +45,33 @@
"""Picks a random directory, requests its cooking via swh-web,
and waits for completion."""
sys.exit(VaultCheck(ctx.obj).main())
+
+
+@cli.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."""
+ ctx.obj.update(kwargs)
+ sys.exit(DepositCheck(ctx.obj).main())
diff --git a/swh/icinga_plugins/deposit.py b/swh/icinga_plugins/deposit.py
new file mode 100644
--- /dev/null
+++ b/swh/icinga_plugins/deposit.py
@@ -0,0 +1,122 @@
+# 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 datetime
+import sys
+import time
+
+from .base_check import BaseCheck
+
+from swh.deposit.client import PublicApiDepositClient
+
+
+class DepositCheck(BaseCheck):
+ TYPE = 'DEPOSIT'
+ DEFAULT_WARNING_THRESHOLD = 120
+ DEFAULT_CRITICAL_THRESHOLD = 3600
+
+ def __init__(self, obj):
+ super().__init__(obj)
+ self._poll_interval = obj['poll_interval']
+ self._archive_path = obj['archive']
+ self._metadata_path = obj['metadata']
+ self._collection = obj['collection']
+
+ self._client = PublicApiDepositClient({
+ 'url': obj['server'],
+ 'auth': {
+ 'username': obj['username'],
+ 'password': obj['password'],
+ },
+ })
+
+ def upload_deposit(self):
+ result = self._client.deposit_create(
+ archive=self._archive_path, metadata=self._metadata_path,
+ collection=self._collection, in_progress=False,
+ slug='check-deposit-%s' % datetime.datetime.now().isoformat())
+ self._deposit_id = result['deposit_id']
+ return result
+
+ def get_deposit_status(self):
+ return self._client.deposit_status(
+ collection=self._collection,
+ deposit_id=self._deposit_id)
+
+ def wait_while_status(self, statuses, start_time, metrics, result):
+ while result['deposit_status'] in statuses:
+ metrics['total_time'] = time.time() - start_time
+ if metrics['total_time'] > self.critical_threshold:
+ self.print_result(
+ 'CRITICAL',
+ f'Timed out while in status '
+ f'{result["deposit_status"]} '
+ f'({metrics["total_time"]}s seconds since deposit '
+ f'started)',
+ **metrics)
+ sys.exit(2)
+
+ time.sleep(self._poll_interval)
+
+ result = self.get_deposit_status()
+
+ return result
+
+ def main(self):
+ start_time = time.time()
+ metrics = {}
+
+ # Upload the archive and metadata
+ result = self.upload_deposit()
+ metrics['upload_time'] = time.time() - start_time
+
+ # Wait for validation
+ result = self.wait_while_status(
+ ['deposited'], start_time, metrics, result)
+ metrics['total_time'] = time.time() - start_time
+ metrics['validation_time'] = \
+ metrics['total_time'] - metrics['upload_time']
+
+ # Check validation succeeded
+ if result['deposit_status'] == 'rejected':
+ self.print_result(
+ 'CRITICAL',
+ f'Deposit was rejected: {result["deposit_status_detail"]}',
+ **metrics)
+ return 2
+
+ # Wait for loading
+ result = self.wait_while_status(
+ ['verified', 'loading'], start_time, metrics, result)
+ metrics['total_time'] = time.time() - start_time
+ metrics['load_time'] = (
+ metrics['total_time'] - metrics['upload_time']
+ - metrics['validation_time'])
+
+ # Check loading succeeded
+ if result['deposit_status'] == 'failed':
+ self.print_result(
+ 'CRITICAL',
+ f'Deposit loading failed: {result["deposit_status_detail"]}',
+ **metrics)
+ return 2
+
+ # Check for unexpected status
+ if result['deposit_status'] != 'done':
+ self.print_result(
+ 'CRITICAL',
+ f'Deposit got unexpected status: {result["deposit_status"]} '
+ f'({result["deposit_status_detail"]})',
+ **metrics)
+ return 2
+
+ # Everything went fine, check total time wasn't too large and
+ # print result
+ (status_code, status) = self.get_status(metrics['total_time'])
+ self.print_result(
+ status,
+ f'Deposit took {metrics["total_time"]:.2f}s and succeeded.',
+ **metrics)
+ return status_code
diff --git a/swh/icinga_plugins/tests/conftest.py b/swh/icinga_plugins/tests/conftest.py
new file mode 100644
--- /dev/null
+++ b/swh/icinga_plugins/tests/conftest.py
@@ -0,0 +1,25 @@
+# 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 pytest
+
+
+@pytest.fixture
+def mocked_time(mocker):
+ start_time = time.time()
+
+ time_offset = 0
+
+ def fake_sleep(seconds):
+ nonlocal time_offset
+ time_offset += seconds
+
+ def fake_time():
+ return start_time + time_offset
+
+ mocker.patch('time.sleep', side_effect=fake_sleep)
+ mocker.patch('time.time', side_effect=fake_time)
diff --git a/swh/icinga_plugins/tests/test_deposit.py b/swh/icinga_plugins/tests/test_deposit.py
new file mode 100644
--- /dev/null
+++ b/swh/icinga_plugins/tests/test_deposit.py
@@ -0,0 +1,308 @@
+# 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 io
+import os
+import re
+import tarfile
+import time
+
+from click.testing import CliRunner
+import pytest
+
+from swh.icinga_plugins.cli import cli
+from .web_scenario import WebScenario
+
+
+BASE_URL = 'mock://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}
+
+'''
+
+
+@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(cli, 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):
+ scenario = WebScenario()
+
+ scenario.add_step(
+ 'post', BASE_URL + '/testcol/',
+ ENTRY_TEMPLATE.format(status='done'))
+
+ 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")
+ assert result.exit_code == 0, result.output
+
+
+def test_deposit_delays(
+ requests_mock, mocker, sample_archive, sample_metadata, mocked_time):
+ scenario = WebScenario()
+
+ scenario.add_step(
+ 'post', BASE_URL + '/testcol/',
+ ENTRY_TEMPLATE.format(status='deposited'))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='verified', status_detail=''))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='loading', status_detail=''))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='done', status_detail=''))
+
+ 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")
+ assert result.exit_code == 0, result.output
+
+
+def test_deposit_timeout(
+ requests_mock, mocker, sample_archive, sample_metadata, mocked_time):
+ scenario = WebScenario()
+
+ scenario.add_step(
+ 'post', BASE_URL + '/testcol/',
+ ENTRY_TEMPLATE.format(status='deposited'),
+ callback=lambda: time.sleep(1500))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='verified', status_detail=''),
+ callback=lambda: time.sleep(1500))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='loading', status_detail=''),
+ 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 re.match(
+ r"^DEPOSIT CRITICAL - Timed out while in status loading "
+ r"\(45[0-9]{2}.0s seconds since deposit started\)\n"
+ r"\| 'total_time' = 45[0-9]{2}.00s\n"
+ r"\| 'upload_time' = 15[0-9]{2}.00s\n"
+ r"\| 'validation_time' = 15[0-9]{2}.00s\n$",
+ result.output)
+ assert result.exit_code == 2, result.output
+
+
+def test_deposit_rejected(
+ requests_mock, mocker, sample_archive, sample_metadata, mocked_time):
+ scenario = WebScenario()
+
+ scenario.add_step(
+ 'post', BASE_URL + '/testcol/',
+ ENTRY_TEMPLATE.format(status='deposited'))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(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, result.output
+
+
+def test_deposit_failed(
+ requests_mock, mocker, sample_archive, sample_metadata, mocked_time):
+ scenario = WebScenario()
+
+ scenario.add_step(
+ 'post', BASE_URL + '/testcol/',
+ ENTRY_TEMPLATE.format(status='deposited'))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='verified', status_detail=''))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='loading', status_detail=''))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(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, result.output
+
+
+def test_deposit_unexpected_status(
+ requests_mock, mocker, sample_archive, sample_metadata, mocked_time):
+ scenario = WebScenario()
+
+ scenario.add_step(
+ 'post', BASE_URL + '/testcol/',
+ ENTRY_TEMPLATE.format(status='deposited'))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='verified', status_detail=''))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(status='loading', status_detail=''))
+ scenario.add_step(
+ 'get', BASE_URL + '/testcol/42/status/',
+ STATUS_TEMPLATE.format(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, result.output
diff --git a/swh/icinga_plugins/tests/web_scenario.py b/swh/icinga_plugins/tests/web_scenario.py
--- a/swh/icinga_plugins/tests/web_scenario.py
+++ b/swh/icinga_plugins/tests/web_scenario.py
@@ -84,4 +84,7 @@
if step.callback:
step.callback()
- return json.dumps(step.response)
+ if isinstance(step.response, str):
+ return step.response
+ else:
+ return json.dumps(step.response)