├── VERSION ├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_groups.py │ ├── test_customers.py │ ├── test_permissions.py │ ├── test_heartbeats.py │ ├── test_keys.py │ ├── test_notes.py │ ├── test_users.py │ ├── test_history.py │ ├── test_http_client.py │ ├── test_blackouts.py │ ├── test_alerts.py │ ├── test_config.py │ ├── test_remoteconfig.py │ └── test_commands.py └── integration │ ├── __init__.py │ ├── test_notes.py │ ├── test_users.py │ ├── test_permissions.py │ ├── test_heartbeats.py │ ├── test_groups.py │ ├── test_customers.py │ ├── test_keys.py │ ├── test_blackouts.py │ ├── test_alerts.py │ └── test_history.py ├── alertaclient ├── auth │ ├── hmac.py │ ├── __init__.py │ ├── gitlab.py │ ├── github.py │ ├── oidc.py │ ├── google.py │ ├── azure.py │ ├── token.py │ └── utils.py ├── commands │ ├── __init__.py │ ├── cmd_help.py │ ├── cmd_revoke.py │ ├── cmd_config.py │ ├── cmd_logout.py │ ├── cmd_top.py │ ├── cmd_scopes.py │ ├── cmd_version.py │ ├── cmd_whoami.py │ ├── cmd_uptime.py │ ├── cmd_groups.py │ ├── cmd_housekeeping.py │ ├── cmd_customers.py │ ├── cmd_token.py │ ├── cmd_status.py │ ├── cmd_perms.py │ ├── cmd_me.py │ ├── cmd_perm.py │ ├── cmd_users.py │ ├── cmd_raw.py │ ├── cmd_customer.py │ ├── cmd_ack.py │ ├── cmd_close.py │ ├── cmd_notes.py │ ├── cmd_unshelve.py │ ├── cmd_unack.py │ ├── cmd_signup.py │ ├── cmd_tag.py │ ├── cmd_untag.py │ ├── cmd_delete.py │ ├── cmd_action.py │ ├── cmd_update.py │ ├── cmd_shelve.py │ ├── cmd_key.py │ ├── cmd_watch.py │ ├── cmd_blackouts.py │ ├── cmd_history.py │ ├── cmd_note.py │ ├── cmd_login.py │ ├── cmd_group.py │ ├── cmd_blackout.py │ ├── cmd_alerts.py │ ├── cmd_heartbeat.py │ ├── cmd_user.py │ ├── cmd_keys.py │ ├── cmd_send.py │ ├── cmd_heartbeats.py │ └── cmd_query.py ├── models │ ├── __init__.py │ ├── customer.py │ ├── permission.py │ ├── group.py │ ├── note.py │ ├── key.py │ ├── user.py │ ├── enums.py │ ├── heartbeat.py │ ├── history.py │ ├── blackout.py │ └── alert.py ├── version.py ├── __main__.py ├── exceptions.py ├── __init__.py ├── utils.py ├── config.py ├── cli.py └── top.py ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── docker.yml │ ├── tests.yml │ └── release.yml ├── mypy.ini ├── .env ├── setup.cfg ├── MANIFEST.in ├── .flake8 ├── .isort.cfg ├── docs └── images │ ├── alerta-top-80x25.png │ └── alerta-top-132x25.png ├── requirements.txt ├── requirements-dev.txt ├── tox.ini ├── Dockerfile ├── examples ├── send.py └── alerta.conf ├── NOTICE ├── docker-compose.ci.yaml ├── CHANGELOG.md ├── .pre-commit-config.yaml ├── setup.py ├── .gitignore ├── Makefile ├── wait-for-it.sh └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 8.5.3 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /alertaclient/auth/hmac.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /alertaclient/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /alertaclient/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /alertaclient/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: satterly 2 | -------------------------------------------------------------------------------- /alertaclient/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '8.5.3' 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PROJECT=alertaclient 2 | COMPOSE_PROJECT_NAME=client 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [pycodestyle] 5 | max-line-length = 120 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION 2 | include LICENSE 3 | include NOTICE 4 | include README.md 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = F403, E501, W503 3 | exclude = *.wsgi 4 | max-line-length = 140 5 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_third_party = click,pytz,requests,requests_hawk,requests_mock,setuptools,tabulate 3 | -------------------------------------------------------------------------------- /alertaclient/__main__.py: -------------------------------------------------------------------------------- 1 | from alertaclient.cli import cli 2 | 3 | cli() # pylint: disable=no-value-for-parameter 4 | -------------------------------------------------------------------------------- /docs/images/alerta-top-80x25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alerta/python-alerta-client/HEAD/docs/images/alerta-top-80x25.png -------------------------------------------------------------------------------- /docs/images/alerta-top-132x25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alerta/python-alerta-client/HEAD/docs/images/alerta-top-132x25.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Click==8.1.7 2 | pytz==2024.1 3 | PyYAML==6.0.1 4 | requests==2.32.3 5 | requests-hawk==1.2.1 6 | tabulate==0.9.0 7 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mypy==1.10.1 2 | pre-commit==3.3.3 3 | pylint==3.2.5 4 | pytest-cov 5 | pytest>=5.4.3 6 | python-dotenv 7 | requests_mock 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | #!dependabot 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "pip" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_help.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.command('help', short_help='Show this help') 5 | @click.pass_context 6 | def cli(ctx): 7 | click.echo(ctx.parent.get_help()) 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36,py37,py38,py39 3 | skip_missing_interpreters=true 4 | 5 | [testenv] 6 | deps = 7 | pytest 8 | requests_mock 9 | 10 | commands = pytest -s {posargs} tests/unit/ 11 | #passenv = * 12 | setenv = 13 | ALERTA_CONF_FILE = 14 | ALERTA_DEFAULT_PROFILE = 15 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_revoke.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from .cmd_key import cli as revoke 4 | 5 | 6 | @click.command('revoke', short_help='Revoke API key') 7 | @click.option('--api-key', '-K', required=True, help='API Key or ID') 8 | @click.pass_context 9 | def cli(ctx, api_key): 10 | ctx.invoke(revoke, delete=api_key) 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | 3 | RUN apk add --no-cache \ 4 | bash \ 5 | build-base \ 6 | libffi-dev \ 7 | openssl-dev \ 8 | postgresql-dev \ 9 | python3-dev 10 | 11 | COPY . /app 12 | WORKDIR /app 13 | 14 | RUN pip install -r requirements.txt 15 | RUN pip install pytest 16 | RUN pip install . 17 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_config.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.command('config', short_help='Display remote client config') 5 | @click.pass_obj 6 | def cli(obj): 7 | """Display client config downloaded from API server.""" 8 | for k, v in obj.items(): 9 | if isinstance(v, list): 10 | v = ', '.join(v) 11 | click.echo(f'{k:20}: {v}') 12 | -------------------------------------------------------------------------------- /tests/integration/test_notes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_notes(self): 12 | # add tests here when /notes endpoints are created 13 | pass 14 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_logout.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.auth.utils import clear_token 4 | 5 | 6 | @click.command('logout', short_help='Clear login credentials') 7 | @click.pass_obj 8 | def cli(obj): 9 | """Clear local login credentials from netrc file.""" 10 | client = obj['client'] 11 | clear_token(client.endpoint) 12 | click.echo('Login credentials removed') 13 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_top.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.top import Screen 4 | 5 | 6 | @click.command('top', short_help='Show top offenders and stats') 7 | @click.pass_obj 8 | def cli(obj): 9 | """Display alerts like unix "top" command.""" 10 | client = obj['client'] 11 | timezone = obj['timezone'] 12 | 13 | screen = Screen(client, timezone) 14 | screen.run() 15 | -------------------------------------------------------------------------------- /tests/integration/test_users.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_user(self): 12 | users = self.client.get_users() 13 | self.assertEqual(users[0].name, 'admin@alerta.io') 14 | self.assertEqual(sorted(users[0].roles), sorted(['admin'])) 15 | self.assertEqual(users[0].status, 'active') 16 | -------------------------------------------------------------------------------- /alertaclient/exceptions.py: -------------------------------------------------------------------------------- 1 | try: 2 | from click import ClickException as ClientException # type: ignore 3 | except Exception: 4 | class ClientException(Exception): # type: ignore 5 | pass 6 | 7 | 8 | class AlertaException(ClientException): 9 | pass 10 | 11 | 12 | class ConfigurationError(AlertaException): 13 | pass 14 | 15 | 16 | class AuthError(AlertaException): 17 | pass 18 | 19 | 20 | class ApiError(AlertaException): 21 | pass 22 | 23 | 24 | class UnknownError(AlertaException): 25 | pass 26 | -------------------------------------------------------------------------------- /tests/integration/test_permissions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_permission(self): 12 | perm = self.client.create_perm(role='websys', scopes=['admin:users', 'admin:keys', 'write']) 13 | self.assertEqual(perm.match, 'websys') 14 | self.assertEqual(sorted(perm.scopes), sorted(['admin:users', 'admin:keys', 'write'])) 15 | -------------------------------------------------------------------------------- /tests/integration/test_heartbeats.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_heartbeat(self): 12 | hb = self.client.heartbeat(origin='app/web01', timeout=10, tags=['london', 'linux']) 13 | self.assertEqual(hb.origin, 'app/web01') 14 | self.assertEqual(hb.event_type, 'Heartbeat') 15 | self.assertEqual(hb.timeout, 10) 16 | self.assertIn('linux', hb.tags) 17 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_scopes.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('scopes', short_help='List scopes') 8 | @click.pass_obj 9 | def cli(obj): 10 | """List scopes.""" 11 | client = obj['client'] 12 | 13 | if obj['output'] == 'json': 14 | r = client.http.get('/scopes') 15 | click.echo(json.dumps(r['scopes'], sort_keys=True, indent=4, ensure_ascii=False)) 16 | else: 17 | headers = {'scope': 'SCOPE'} 18 | click.echo(tabulate([s.tabular() for s in client.get_scopes()], headers=headers, tablefmt=obj['output'])) 19 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_version.py: -------------------------------------------------------------------------------- 1 | import click 2 | from requests import __version__ as requests_version 3 | 4 | from alertaclient.version import __version__ as client_version 5 | 6 | 7 | @click.command('version', short_help='Display version info') 8 | @click.pass_obj 9 | @click.pass_context 10 | def cli(ctx, obj): 11 | """Show Alerta server and client versions.""" 12 | client = obj['client'] 13 | click.echo('alerta {}'.format(client.mgmt_status()['version'])) 14 | click.echo(f'alerta client {client_version}') 15 | click.echo(f'requests {requests_version}') 16 | click.echo(f'click {click.__version__}') 17 | ctx.exit() 18 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_whoami.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.command('whoami', short_help='Display current logged in user') 5 | @click.option('--show-userinfo', '-u', is_flag=True, help='Display userinfo') 6 | @click.pass_obj 7 | def cli(obj, show_userinfo): 8 | """Display logged in user or full userinfo.""" 9 | client = obj['client'] 10 | userinfo = client.userinfo() 11 | if show_userinfo: 12 | for k, v in userinfo.items(): 13 | if isinstance(v, list): 14 | v = ', '.join(v) 15 | click.echo(f'{k:20}: {v}') 16 | else: 17 | click.echo(userinfo['preferred_username']) 18 | -------------------------------------------------------------------------------- /alertaclient/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info < (3,): 4 | raise ImportError( 5 | """You are running Alerta 6.0 on Python 2 6 | 7 | Alerta 6.0 and above are no longer compatible with Python 2. 8 | 9 | Make sure you have pip >= 9.0 to avoid this kind of issue, 10 | as well as setuptools >= 24.2: 11 | 12 | $ pip install pip setuptools --upgrade 13 | 14 | Your choices: 15 | 16 | - Upgrade to Python 3. 17 | 18 | - Install an older version of Alerta: 19 | 20 | $ pip install 'alerta<6.0' 21 | 22 | See the following URL for more up-to-date information: 23 | 24 | https://github.com/alerta/alerta/wiki/Python-3 25 | 26 | """) 27 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_uptime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import click 4 | 5 | 6 | @click.command('uptime', short_help='Display server uptime') 7 | @click.pass_obj 8 | def cli(obj): 9 | """Display API server uptime in days, hours.""" 10 | client = obj['client'] 11 | status = client.mgmt_status() 12 | 13 | now = datetime.fromtimestamp(int(status['time']) / 1000.0) 14 | uptime = datetime(1, 1, 1) + timedelta(seconds=int(status['uptime']) / 1000.0) 15 | 16 | click.echo('{} up {} days {:02d}:{:02d}'.format( 17 | now.strftime('%H:%M'), 18 | uptime.day - 1, uptime.hour, uptime.minute 19 | )) 20 | -------------------------------------------------------------------------------- /examples/send.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from alertaclient.api import Client 4 | 5 | client = Client() 6 | 7 | try: 8 | id, alert, message = client.send_alert( 9 | resource='web-server-01', 10 | event='HttpError', 11 | correlate=['HttpOK'], 12 | group='Web', 13 | environment='Production', 14 | service=['theguardian.com'], 15 | severity='major', 16 | value='Bad Gateway (502)', 17 | text='Web server error.', 18 | tags=['web', 'dc1', 'london'], 19 | attributes={'customer': 'The Guardian'} 20 | ) 21 | print(alert) 22 | except Exception as e: 23 | print(e) 24 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_groups.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('groups', short_help='List user groups') 8 | @click.pass_obj 9 | def cli(obj): 10 | """List groups.""" 11 | client = obj['client'] 12 | 13 | if obj['output'] == 'json': 14 | r = client.http.get('/groups') 15 | click.echo(json.dumps(r['groups'], sort_keys=True, indent=4, ensure_ascii=False)) 16 | else: 17 | headers = {'id': 'ID', 'name': 'NAME', 'count': 'USERS', 'text': 'DESCRIPTION'} 18 | click.echo(tabulate([g.tabular() for g in client.get_users_groups()], headers=headers, tablefmt=obj['output'])) 19 | -------------------------------------------------------------------------------- /examples/alerta.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | timezone = Europe/London 3 | ;timezone = Australia/Sydney 4 | colour = yes 5 | output = psql ; Postgres-style table format 6 | 7 | [profile production] 8 | endpoint = https://uk.alerta.io 9 | key = xRJXNbWX0feCC2vAASmJRNF7iTbtlIzRvl-T1ykA 10 | 11 | [profile backup] 12 | endpoint = http://au.alerta.io 13 | key = Y154v1Bme4tJ08j5P9EpMs5Zwr0Je3wivg0sfGSc 14 | 15 | [profile test] 16 | endpoint = http://api.alerta.io 17 | key = demo-key 18 | 19 | [profile development] 20 | endpoint = http://localhost:8080 21 | key = demo-key 22 | 23 | [profile hmac-auth] 24 | endpoint = http://localhost:9080/api 25 | key = access-key 26 | secret = secret-key 27 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Alerta monitoring system and console 2 | Copyright 2012-2019 Nick Satterly 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_housekeeping.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.command('housekeeping', short_help='Expired and clears old alerts.') 5 | @click.option('--expired', metavar='EXPIRED_DELETE_HOURS', required=False, 6 | help='Delete expired/closed alertas after this many hours.') 7 | @click.option('--info', metavar='INFO_DELETE_HOURS', required=False, 8 | help='Delete informational alerta after this many hours.') 9 | @click.pass_obj 10 | def cli(obj, expired=None, info=None): 11 | """Trigger the expiration and deletion of alerts.""" 12 | client = obj['client'] 13 | client.housekeeping(expired_delete_hours=expired, info_delete_hours=info) 14 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_customers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('customers', short_help='List customer lookups') 8 | @click.pass_obj 9 | def cli(obj): 10 | """List customer lookups.""" 11 | client = obj['client'] 12 | 13 | if obj['output'] == 'json': 14 | r = client.http.get('/customers') 15 | click.echo(json.dumps(r['customers'], sort_keys=True, indent=4, ensure_ascii=False)) 16 | else: 17 | headers = {'id': 'ID', 'customer': 'CUSTOMER', 'match': 'GROUP'} 18 | click.echo(tabulate([c.tabular() for c in client.get_customers()], headers=headers, tablefmt=obj['output'])) 19 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_token.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.auth.token import Jwt 4 | from alertaclient.auth.utils import get_token 5 | 6 | 7 | @click.command('token', short_help='Display current auth token') 8 | @click.option('--decode', '-D', is_flag=True, help='Decode auth token.') 9 | @click.pass_obj 10 | def cli(obj, decode): 11 | """Display the auth token for logged in user, with option to decode it.""" 12 | client = obj['client'] 13 | token = get_token(client.endpoint) 14 | if decode: 15 | jwt = Jwt() 16 | for k, v in jwt.parse(token).items(): 17 | if isinstance(v, list): 18 | v = ', '.join(v) 19 | click.echo(f'{k:20}: {v}') 20 | else: 21 | click.echo(token) 22 | -------------------------------------------------------------------------------- /alertaclient/models/customer.py: -------------------------------------------------------------------------------- 1 | class Customer: 2 | 3 | def __init__(self, match, customer, **kwargs): 4 | self.id = kwargs.get('id', None) 5 | self.match = match 6 | self.customer = customer 7 | 8 | def __repr__(self): 9 | return 'Customer(id={!r}, match={!r}, customer={!r})'.format( 10 | self.id, self.match, self.customer) 11 | 12 | @classmethod 13 | def parse(cls, json): 14 | return Customer( 15 | id=json.get('id', None), 16 | match=json.get('match', None), 17 | customer=json.get('customer', None) 18 | ) 19 | 20 | def tabular(self): 21 | return { 22 | 'id': self.id, 23 | 'match': self.match, 24 | 'customer': self.customer 25 | } 26 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_status.py: -------------------------------------------------------------------------------- 1 | import click 2 | from tabulate import tabulate 3 | 4 | 5 | @click.command('status', short_help='Display status and metrics') 6 | @click.pass_obj 7 | def cli(obj): 8 | """Display API server switch status and usage metrics.""" 9 | client = obj['client'] 10 | metrics = client.mgmt_status()['metrics'] 11 | headers = {'title': 'METRIC', 'type': 'TYPE', 'name': 'NAME', 'value': 'VALUE', 'average': 'AVERAGE'} 12 | click.echo(tabulate([{ 13 | 'title': m['title'], 14 | 'type': m['type'], 15 | 'name': '{}.{}'.format(m['group'], m['name']), 16 | 'value': m.get('value', None) or m.get('count', 0), 17 | 'average': int(m['totalTime']) * 1.0 / int(m['count']) if m['type'] == 'timer' else None 18 | } for m in metrics], headers=headers, tablefmt=obj['output'])) 19 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_perms.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('perms', short_help='List role-permission lookups') 8 | @click.option('--scope', 'scopes', multiple=True, help='Filter roles by scope eg. admin:keys, write:alerts') 9 | @click.pass_obj 10 | def cli(obj, scopes): 11 | """List permissions.""" 12 | client = obj['client'] 13 | query = [('scopes', s) for s in scopes] 14 | 15 | if obj['output'] == 'json': 16 | r = client.http.get('/perms', query) 17 | click.echo(json.dumps(r['permissions'], sort_keys=True, indent=4, ensure_ascii=False)) 18 | else: 19 | headers = {'id': 'ID', 'scopes': 'SCOPES', 'match': 'ROLE'} 20 | click.echo(tabulate([p.tabular() for p in client.get_perms(query)], headers=headers, tablefmt=obj['output'])) 21 | -------------------------------------------------------------------------------- /alertaclient/models/permission.py: -------------------------------------------------------------------------------- 1 | class Permission: 2 | 3 | def __init__(self, match, scopes, **kwargs): 4 | self.id = kwargs.get('id', None) 5 | self.match = match 6 | self.scopes = scopes or list() 7 | 8 | def __repr__(self): 9 | return 'Perm(id={!r}, match={!r}, scopes={!r})'.format( 10 | self.id, self.match, self.scopes) 11 | 12 | @classmethod 13 | def parse(cls, json): 14 | if not isinstance(json.get('scopes', []), list): 15 | raise ValueError('scopes must be a list') 16 | 17 | return Permission( 18 | id=json.get('id', None), 19 | match=json.get('match', None), 20 | scopes=json.get('scopes', list()) 21 | ) 22 | 23 | def tabular(self): 24 | return { 25 | 'id': self.id, 26 | 'match': self.match, 27 | 'scopes': ','.join(self.scopes) 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose.ci.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | alerta: 5 | image: alerta/alerta-web 6 | ports: 7 | - "8080:8080" 8 | depends_on: 9 | - db 10 | environment: 11 | # - DEBUG=1 # remove this line to turn DEBUG off 12 | - DATABASE_URL=postgres://postgres:postgres@db:5432/monitoring 13 | - AUTH_REQUIRED=True 14 | - ADMIN_USERS=admin@alerta.io,devops@alerta.io #default password: alerta 15 | - ADMIN_KEY=demo-key # assigned to first user in ADMIN_USERS list 16 | # - PLUGINS=reject,blackout,normalise,enhance 17 | 18 | db: 19 | image: postgres:14 20 | environment: 21 | - POSTGRES_DB=monitoring 22 | - POSTGRES_USER=postgres 23 | - POSTGRES_PASSWORD=postgres 24 | restart: always 25 | 26 | sut: 27 | build: . 28 | depends_on: 29 | - alerta 30 | command: ["./wait-for-it.sh", "alerta:8080", "-t", "60", "--", "pytest", "tests/integration/"] 31 | -------------------------------------------------------------------------------- /alertaclient/auth/gitlab.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from uuid import uuid4 3 | 4 | from alertaclient.auth.token import TokenHandler 5 | 6 | 7 | def login(client, gitlab_url, client_id): 8 | xsrf_token = str(uuid4()) 9 | redirect_uri = 'http://127.0.0.1:9004' 10 | url = ( 11 | '{gitlab_url}/oauth/authorize?' 12 | 'response_type=code&' 13 | 'client_id={client_id}&' 14 | 'redirect_uri={redirect_uri}&' 15 | 'scope=openid&' 16 | 'state={state}' 17 | ).format( 18 | gitlab_url=gitlab_url, 19 | client_id=client_id, 20 | redirect_uri=redirect_uri, 21 | state=xsrf_token 22 | ) 23 | 24 | webbrowser.open(url, new=0, autoraise=True) 25 | auth = TokenHandler() 26 | access_token = auth.get_access_token(xsrf_token) 27 | 28 | data = { 29 | 'code': access_token, 30 | 'clientId': client_id, 31 | 'redirectUri': redirect_uri 32 | } 33 | return client.token('gitlab', data) 34 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_me.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | 6 | @click.command('me', short_help='Update current user') 7 | @click.option('--name', help='Name of user') 8 | @click.option('--email', help='Email address (login username)') 9 | @click.option('--password', help='Password') 10 | @click.option('--status', help='Status eg. active, inactive') 11 | @click.option('--text', help='Description of user') 12 | @click.pass_obj 13 | def cli(obj, name, email, password, status, text): 14 | """Update current user details, including password reset.""" 15 | if not any([name, email, password, status, text]): 16 | click.echo('Nothing to update.') 17 | sys.exit(1) 18 | 19 | client = obj['client'] 20 | try: 21 | user = client.update_me(name=name, email=email, password=password, status=status, attributes=None, text=text) 22 | except Exception as e: 23 | click.echo(f'ERROR: {e}', err=True) 24 | sys.exit(1) 25 | click.echo(user.id) 26 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_perm.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | 6 | @click.command('perm', short_help='Add role-permission lookup') 7 | @click.option('--role', help='Role name') 8 | @click.option('--scope', 'scopes', multiple=True, help='List of permissions eg. admin:keys, write:alerts') 9 | @click.option('--delete', '-D', metavar='ID', help='Delete role using ID') 10 | @click.pass_obj 11 | def cli(obj, role, scopes, delete): 12 | """Add or delete role-to-permission lookup entry.""" 13 | client = obj['client'] 14 | if delete: 15 | client.delete_perm(delete) 16 | else: 17 | if not role: 18 | raise click.UsageError('Missing option "--role".') 19 | if not scopes: 20 | raise click.UsageError('Missing option "--scope".') 21 | try: 22 | perm = client.create_perm(role, scopes) 23 | except Exception as e: 24 | click.echo(f'ERROR: {e}', err=True) 25 | sys.exit(1) 26 | click.echo(perm.id) 27 | -------------------------------------------------------------------------------- /alertaclient/auth/github.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from uuid import uuid4 3 | 4 | from alertaclient.auth.token import TokenHandler 5 | 6 | 7 | def login(client, github_url, client_id): 8 | xsrf_token = str(uuid4()) 9 | redirect_uri = 'http://127.0.0.1:9004' 10 | url = ( 11 | '{github_url}/login/oauth/authorize?' 12 | 'client_id={client_id}&' 13 | 'redirect_uri={redirect_uri}&' 14 | 'scope=user:email%20read:org&' 15 | 'state={state}&' 16 | 'allow_signup=false' 17 | ).format( 18 | github_url=github_url, 19 | client_id=client_id, 20 | redirect_uri=redirect_uri, 21 | state=xsrf_token 22 | ) 23 | 24 | webbrowser.open(url, new=0, autoraise=True) 25 | auth = TokenHandler() 26 | access_token = auth.get_access_token(xsrf_token) 27 | 28 | data = { 29 | 'code': access_token, 30 | 'clientId': client_id, 31 | 'redirectUri': redirect_uri 32 | } 33 | return client.token('github', data) 34 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_users.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('users', short_help='List users') 8 | @click.option('--role', 'roles', multiple=True, help='Filter users by role') 9 | @click.pass_obj 10 | def cli(obj, roles): 11 | """List users.""" 12 | client = obj['client'] 13 | query = [('roles', r) for r in roles] 14 | 15 | if obj['output'] == 'json': 16 | r = client.http.get('/users', query) 17 | click.echo(json.dumps(r['users'], sort_keys=True, indent=4, ensure_ascii=False)) 18 | else: 19 | timezone = obj['timezone'] 20 | headers = {'id': 'ID', 'name': 'USER', 'email': 'EMAIL', 'roles': 'ROLES', 'status': 'STATUS', 'text': 'TEXT', 21 | 'createTime': 'CREATED', 'updateTime': 'LAST UPDATED', 'lastLogin': 'LAST LOGIN', 'email_verified': 'VERIFIED'} 22 | click.echo( 23 | tabulate([u.tabular(timezone) for u in client.get_users(query)], headers=headers, tablefmt=obj['output']) 24 | ) 25 | -------------------------------------------------------------------------------- /alertaclient/auth/oidc.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from uuid import uuid4 3 | 4 | from alertaclient.auth.token import TokenHandler 5 | 6 | 7 | def login(client, oidc_auth_url, client_id): 8 | xsrf_token = str(uuid4()) 9 | redirect_uri = 'http://localhost:9004' # azure only supports 'localhost' 10 | 11 | url = ( 12 | '{oidc_auth_url}?' 13 | 'response_type=code' 14 | '&client_id={client_id}' 15 | '&redirect_uri={redirect_uri}' 16 | '&scope=openid%20profile%20email' 17 | '&state={state}' 18 | ).format( 19 | oidc_auth_url=oidc_auth_url, 20 | client_id=client_id, 21 | redirect_uri=redirect_uri, 22 | state=xsrf_token 23 | ) 24 | 25 | webbrowser.open(url, new=0, autoraise=True) 26 | auth = TokenHandler() 27 | access_token = auth.get_access_token(xsrf_token) 28 | 29 | data = { 30 | 'code': access_token, 31 | 'clientId': client_id, 32 | 'redirectUri': redirect_uri 33 | } 34 | return client.token('openid', data) 35 | -------------------------------------------------------------------------------- /alertaclient/auth/google.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from uuid import uuid4 3 | 4 | from alertaclient.auth.token import TokenHandler 5 | 6 | 7 | def login(client, username, client_id): 8 | xsrf_token = str(uuid4()) 9 | redirect_uri = 'http://127.0.0.1:9004' 10 | url = ( 11 | 'https://accounts.google.com/o/oauth2/v2/auth?' 12 | 'scope=email%20profile&' 13 | 'response_type=code&' 14 | 'client_id={client_id}&' 15 | 'redirect_uri={redirect_uri}&' 16 | 'state={state}&' 17 | 'login_hint={username}' 18 | ).format( 19 | client_id=client_id, 20 | redirect_uri=redirect_uri, 21 | state=xsrf_token, 22 | username=username 23 | ) 24 | 25 | webbrowser.open(url, new=0, autoraise=True) 26 | auth = TokenHandler() 27 | access_token = auth.get_access_token(xsrf_token) 28 | 29 | data = { 30 | 'code': access_token, 31 | 'clientId': client_id, 32 | 'redirectUri': redirect_uri 33 | } 34 | return client.token('google', data) 35 | -------------------------------------------------------------------------------- /alertaclient/auth/azure.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from uuid import uuid4 3 | 4 | from alertaclient.auth.token import TokenHandler 5 | 6 | 7 | def login(client, azure_tenant, client_id): 8 | xsrf_token = str(uuid4()) 9 | redirect_uri = 'http://localhost:9004' 10 | 11 | url = ( 12 | 'https://login.microsoftonline.com/{azure_tenant}/oauth2/v2.0/authorize?' 13 | 'response_type=code' 14 | '&client_id={client_id}' 15 | '&redirect_uri={redirect_uri}' 16 | '&scope=openid%20profile%20email' 17 | '&state={state}' 18 | ).format( 19 | azure_tenant=azure_tenant, 20 | client_id=client_id, 21 | redirect_uri=redirect_uri, 22 | state=xsrf_token 23 | ) 24 | 25 | webbrowser.open(url, new=0, autoraise=True) 26 | auth = TokenHandler() 27 | access_token = auth.get_access_token(xsrf_token) 28 | 29 | data = { 30 | 'code': access_token, 31 | 'clientId': client_id, 32 | 'redirectUri': redirect_uri 33 | } 34 | return client.token('azure', data) 35 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_raw.py: -------------------------------------------------------------------------------- 1 | import click 2 | from tabulate import tabulate 3 | 4 | from alertaclient.utils import build_query 5 | 6 | 7 | @click.command('raw', short_help='Show alert raw data') 8 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 9 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 10 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters): 13 | """Show raw data for alerts.""" 14 | client = obj['client'] 15 | if ids: 16 | query = [('id', x) for x in ids] 17 | elif query: 18 | query = [('q', query)] 19 | else: 20 | query = build_query(filters) 21 | alerts = client.search(query) 22 | 23 | headers = {'id': 'ID', 'rawData': 'RAW DATA'} 24 | click.echo( 25 | tabulate([{'id': a.id, 'rawData': a.raw_data} for a in alerts], headers=headers, tablefmt=obj['output'])) 26 | -------------------------------------------------------------------------------- /tests/unit/test_groups.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class GroupTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.key = """ 14 | { 15 | "group": { 16 | "count": 0, 17 | "href": "http://localhost:8080/group/8ed5d256-4205-4dfc-b25d-185bd019cb21", 18 | "id": "8ed5d256-4205-4dfc-b25d-185bd019cb21", 19 | "name": "myGroup", 20 | "text": "test group" 21 | }, 22 | "id": "8ed5d256-4205-4dfc-b25d-185bd019cb21", 23 | "status": "ok" 24 | } 25 | """ 26 | 27 | @requests_mock.mock() 28 | def test_group(self, m): 29 | m.post('http://localhost:8080/group', text=self.key) 30 | group = self.client.create_group(name='myGroup', text='test group') 31 | self.assertEqual(group.name, 'myGroup') 32 | self.assertEqual(group.text, 'test group') 33 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_customer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | 6 | @click.command('customer', short_help='Add customer lookup') 7 | @click.option('--customer', help='customer name') 8 | @click.option('--org', '--group', '--domain', '--role', 'match', help='Used to lookup customer') 9 | @click.option('--delete', '-D', metavar='ID', help='Delete customer lookup using ID') 10 | @click.pass_obj 11 | def cli(obj, customer, match, delete): 12 | """Add group/org/domain/role-to-customer or delete lookup entry.""" 13 | client = obj['client'] 14 | if delete: 15 | client.delete_customer(delete) 16 | else: 17 | if not customer: 18 | raise click.UsageError('Missing option "--customer".') 19 | if not match: 20 | raise click.UsageError('Missing option "--org" / "--group" / "--domain" / "--role".') 21 | try: 22 | customer = client.create_customer(customer, match) 23 | except Exception as e: 24 | click.echo(f'ERROR: {e}', err=True) 25 | sys.exit(1) 26 | click.echo(customer.id) 27 | -------------------------------------------------------------------------------- /tests/integration/test_groups.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_group(self): 12 | group = self.client.create_group(name='myGroup', text='test group') 13 | 14 | group_id = group.id 15 | 16 | self.assertEqual(group.name, 'myGroup') 17 | self.assertEqual(group.text, 'test group') 18 | 19 | group = self.client.update_group(group_id, name='newGroup', text='updated group text') 20 | self.assertEqual(group.name, 'newGroup') 21 | self.assertEqual(group.text, 'updated group text') 22 | 23 | group = self.client.create_group(name='myGroup2', text='test group2') 24 | 25 | groups = self.client.get_users_groups() 26 | self.assertEqual(len(groups), 2, groups) 27 | 28 | self.client.delete_group(group_id) 29 | 30 | groups = self.client.get_users_groups() 31 | self.assertEqual(len(groups), 1) 32 | -------------------------------------------------------------------------------- /tests/unit/test_customers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class CustomerTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.customer = """ 14 | { 15 | "customer": { 16 | "customer": "ACME Corp.", 17 | "href": "http://localhost:8080/customer/9bb97023-186e-4744-a59d-d18f641eee52", 18 | "id": "9bb97023-186e-4744-a59d-d18f641eee52", 19 | "match": "example.com" 20 | }, 21 | "id": "9bb97023-186e-4744-a59d-d18f641eee52", 22 | "status": "ok" 23 | } 24 | """ 25 | 26 | @requests_mock.mock() 27 | def test_customer(self, m): 28 | m.post('http://localhost:8080/customer', text=self.customer) 29 | customer = self.client.create_customer(customer='ACME Corp.', match='example.com') 30 | self.assertEqual(customer.customer, 'ACME Corp.') 31 | self.assertEqual(customer.match, 'example.com') 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v8.5.2 (2023-03-18) 2 | 3 | ### Refactor 4 | 5 | - convert formatted strings to f-strings (#272) 6 | 7 | ## v8.5.1 (2021-11-21) 8 | 9 | ### Feat 10 | 11 | - Add a --alert flag to alert keys to alert on expired and expiring key (#274) 12 | - Add option to use custom value when creating API key (#270) 13 | 14 | ### Refactor 15 | 16 | - convert formatted strings to f-strings (#272) 17 | - assign api key directly (#271) 18 | 19 | ## v8.5.0 (2021-04-18) 20 | 21 | ### Fix 22 | 23 | - improve alert note command (#263) 24 | - consistent use of ID as metavar (#262) 25 | - add scopes cmd and minor fixes (#257) 26 | - **build**: run tests against correct branch 27 | 28 | ### Feat 29 | 30 | - add examples for group cmd (#261) 31 | - add and remove users to/from groups (#260) 32 | - add option to list users to group cmd (#259) 33 | - add option to list groups to user cmd (#258) 34 | - add alerts command for list alert attributes (#256) 35 | - show user details (#255) 36 | - add option to show decoded auth token claims (#254) 37 | - **auth**: add HMAC authentication as config option (#248) 38 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_ack.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import action_progressbar, build_query 4 | 5 | 6 | @click.command('ack', short_help='Acknowledge alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--text', help='Message associated with status change') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters, text): 13 | """Set alert status to 'ack'.""" 14 | client = obj['client'] 15 | if ids: 16 | total = len(ids) 17 | else: 18 | if query: 19 | query = [('q', query)] 20 | else: 21 | query = build_query(filters) 22 | total, _, _ = client.get_count(query) 23 | ids = [a.id for a in client.get_alerts(query)] 24 | 25 | action_progressbar(client, action='ack', ids=ids, label=f'Acking {total} alerts', text=text) 26 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_close.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import action_progressbar, build_query 4 | 5 | 6 | @click.command('ack', short_help='Close alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--text', help='Message associated with status change') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters, text): 13 | """Set alert status to 'closed'.""" 14 | client = obj['client'] 15 | if ids: 16 | total = len(ids) 17 | else: 18 | if query: 19 | query = [('q', query)] 20 | else: 21 | query = build_query(filters) 22 | total, _, _ = client.get_count(query) 23 | ids = [a.id for a in client.get_alerts(query)] 24 | 25 | action_progressbar(client, action='close', ids=ids, label=f'Closing {total} alerts', text=text) 26 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_notes.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('notes', short_help='List notes') 8 | @click.option('--alert-id', '-i', metavar='ID', help='alert IDs (can use short 8-char id)') 9 | @click.pass_obj 10 | def cli(obj, alert_id): 11 | """List notes.""" 12 | client = obj['client'] 13 | if alert_id: 14 | if obj['output'] == 'json': 15 | r = client.http.get(f'/alert/{alert_id}/notes') 16 | click.echo(json.dumps(r['notes'], sort_keys=True, indent=4, ensure_ascii=False)) 17 | else: 18 | timezone = obj['timezone'] 19 | headers = { 20 | 'id': 'NOTE ID', 'text': 'NOTE', 'user': 'USER', 'type': 'TYPE', 'attributes': 'ATTRIBUTES', 21 | 'createTime': 'CREATED', 'updateTime': 'UPDATED', 'related': 'RELATED ID', 'customer': 'CUSTOMER' 22 | } 23 | click.echo(tabulate([n.tabular(timezone) for n in client.get_alert_notes(alert_id)], headers=headers, tablefmt=obj['output'])) 24 | else: 25 | raise click.UsageError('Need "--alert-id" to list notes.') 26 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_unshelve.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import action_progressbar, build_query 4 | 5 | 6 | @click.command('unshelve', short_help='Un-shelve alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--text', help='Message associated with status change') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters, text): 13 | """Set alert status to 'open'.""" 14 | client = obj['client'] 15 | if ids: 16 | total = len(ids) 17 | else: 18 | if query: 19 | query = [('q', query)] 20 | else: 21 | query = build_query(filters) 22 | total, _, _ = client.get_count(query) 23 | ids = [a.id for a in client.get_alerts(query)] 24 | 25 | action_progressbar(client, 'unshelve', ids, label=f'Un-shelving {total} alerts', text=text) 26 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_unack.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import action_progressbar, build_query 4 | 5 | 6 | @click.command('unack', short_help='Un-acknowledge alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--text', help='Message associated with status change') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters, text): 13 | """Set alert status to 'open'.""" 14 | client = obj['client'] 15 | if ids: 16 | total = len(ids) 17 | else: 18 | if query: 19 | query = [('q', query)] 20 | else: 21 | query = build_query(filters) 22 | total, _, _ = client.get_count(query) 23 | ids = [a.id for a in client.get_alerts(query)] 24 | 25 | action_progressbar(client, action='unack', ids=ids, label=f'Un-acking {total} alerts', text=text) 26 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_signup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | from alertaclient.exceptions import AuthError 6 | 7 | 8 | @click.command('signup', short_help='Sign-up new user') 9 | @click.option('--name', help='Name of user') 10 | @click.option('--email', help='Email address (login username)') 11 | @click.option('--password', help='Password') 12 | @click.option('--status', help='Status eg. active, inactive') 13 | @click.option('--text', help='Description of user') 14 | @click.pass_obj 15 | def cli(obj, name, email, password, status, text): 16 | """Create new Basic Auth user.""" 17 | client = obj['client'] 18 | if not email: 19 | raise click.UsageError('Need "--email" to sign-up new user.') 20 | if not password: 21 | raise click.UsageError('Need "--password" to sign-up new user.') 22 | try: 23 | r = client.signup(name=name, email=email, password=password, status=status, attributes=None, text=text) 24 | except Exception as e: 25 | click.echo(f'ERROR: {e}', err=True) 26 | sys.exit(1) 27 | if 'token' in r: 28 | click.echo('Signed Up.') 29 | else: 30 | raise AuthError 31 | -------------------------------------------------------------------------------- /tests/integration/test_customers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_customer(self): 12 | customer = self.client.create_customer(customer='ACME Corp.', match='example.com') 13 | 14 | customer_id = customer.id 15 | 16 | self.assertEqual(customer.customer, 'ACME Corp.') 17 | self.assertEqual(customer.match, 'example.com') 18 | 19 | customer = self.client.update_customer(customer_id, customer='Foo Corp.', match='foo.com') 20 | 21 | self.assertEqual(customer.customer, 'Foo Corp.') 22 | self.assertEqual(customer.match, 'foo.com') 23 | 24 | customer = self.client.create_customer(customer='Quetzal Inc.', match='quetzal.io') 25 | 26 | customers = self.client.get_customers() 27 | self.assertEqual(len(customers), 2) 28 | 29 | self.client.delete_customer(customer_id) 30 | 31 | customers = self.client.get_customers() 32 | self.assertEqual(len(customers), 1) 33 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_tag.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import build_query 4 | 5 | 6 | @click.command('tag', short_help='Tag alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--tag', '-T', 'tags', required=True, multiple=True, help='List of tags') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters, tags): 13 | """Add tags to alerts.""" 14 | client = obj['client'] 15 | if ids: 16 | total = len(ids) 17 | else: 18 | if query: 19 | query = [('q', query)] 20 | else: 21 | query = build_query(filters) 22 | total, _, _ = client.get_count(query) 23 | ids = [a.id for a in client.get_alerts(query)] 24 | 25 | with click.progressbar(ids, label=f'Tagging {total} alerts') as bar: 26 | for id in bar: 27 | client.tag_alert(id, tags) 28 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_untag.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import build_query 4 | 5 | 6 | @click.command('untag', short_help='Untag alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--tag', '-T', 'tags', required=True, multiple=True, help='List of tags') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters, tags): 13 | """Remove tags from alerts.""" 14 | client = obj['client'] 15 | if ids: 16 | total = len(ids) 17 | else: 18 | if query: 19 | query = [('q', query)] 20 | else: 21 | query = build_query(filters) 22 | total, _, _ = client.get_count(query) 23 | ids = [a.id for a in client.get_alerts(query)] 24 | 25 | with click.progressbar(ids, label=f'Untagging {total} alerts') as bar: 26 | for id in bar: 27 | client.untag_alert(id, tags) 28 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_delete.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import build_query 4 | 5 | 6 | @click.command('delete', short_help='Delete alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.pass_obj 11 | def cli(obj, ids, query, filters): 12 | """Delete alerts.""" 13 | client = obj['client'] 14 | if ids: 15 | total = len(ids) 16 | else: 17 | if not (query or filters): 18 | click.confirm('Deleting all alerts. Do you want to continue?', abort=True) 19 | if query: 20 | query = [('q', query)] 21 | else: 22 | query = build_query(filters) 23 | total, _, _ = client.get_count(query) 24 | ids = [a.id for a in client.get_alerts(query)] 25 | 26 | with click.progressbar(ids, label=f'Deleting {total} alerts') as bar: 27 | for id in bar: 28 | client.delete_alert(id) 29 | -------------------------------------------------------------------------------- /tests/unit/test_permissions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class PermissionTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.perm = """ 14 | { 15 | "id": "584f38f4-b44e-4d87-9b61-c106d21bcc7a", 16 | "permission": { 17 | "href": "http://localhost:8080/perm/584f38f4-b44e-4d87-9b61-c106d21bcc7a", 18 | "id": "584f38f4-b44e-4d87-9b61-c106d21bcc7a", 19 | "match": "websys", 20 | "scopes": [ 21 | "admin:users", 22 | "admin:keys", 23 | "write" 24 | ] 25 | }, 26 | "status": "ok" 27 | } 28 | """ 29 | 30 | @requests_mock.mock() 31 | def test_permission(self, m): 32 | m.post('http://localhost:8080/perm', text=self.perm) 33 | perm = self.client.create_perm(role='websys', scopes=['admin:users', 'admin:keys', 'write']) 34 | self.assertEqual(perm.match, 'websys') 35 | self.assertEqual(sorted(perm.scopes), sorted(['admin:users', 'admin:keys', 'write'])) 36 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_action.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import action_progressbar, build_query 4 | 5 | 6 | @click.command('action', short_help='Action alerts') 7 | @click.option('--action', '-a', metavar='ACTION', help='Custom action (user-defined)') 8 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 9 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 10 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 11 | @click.option('--text', help='Message associated with action') 12 | @click.pass_obj 13 | def cli(obj, action, ids, query, filters, text): 14 | """Take action on alert'.""" 15 | client = obj['client'] 16 | if ids: 17 | total = len(ids) 18 | else: 19 | if query: 20 | query = [('q', query)] 21 | else: 22 | query = build_query(filters) 23 | total, _, _ = client.get_count(query) 24 | ids = [a.id for a in client.get_alerts(query)] 25 | 26 | label = f'Action ({action}) {total} alerts' 27 | action_progressbar(client, action=action, ids=ids, label=label, text=text) 28 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_update.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import build_query 4 | 5 | 6 | @click.command('update', short_help='Update alert attributes') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--attributes', '-A', metavar='KEY=VALUE', multiple=True, required=True, help='List of attributes eg. priority=high') 11 | @click.pass_obj 12 | def cli(obj, ids, query, filters, attributes): 13 | """Update alert attributes.""" 14 | client = obj['client'] 15 | if ids: 16 | total = len(ids) 17 | else: 18 | if query: 19 | query = [('q', query)] 20 | else: 21 | query = build_query(filters) 22 | total, _, _ = client.get_count(query) 23 | ids = [a.id for a in client.get_alerts(query)] 24 | 25 | with click.progressbar(ids, label=f'Updating {total} alerts') as bar: 26 | for id in bar: 27 | client.update_attributes(id, dict(a.split('=') for a in attributes)) 28 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: [ master, release/* ] 6 | tags: [ '**' ] 7 | 8 | env: 9 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 10 | 11 | jobs: 12 | build: 13 | name: Build & Push 14 | runs-on: ubuntu-latest 15 | env: 16 | REPOSITORY_URL: ghcr.io 17 | IMAGE_NAME: ${{ github.repository_owner }}/alerta-cli 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Build image 22 | id: docker-build 23 | run: >- 24 | docker build 25 | -t $IMAGE_NAME 26 | -t $REPOSITORY_URL/$IMAGE_NAME:$(cat VERSION) 27 | -t $REPOSITORY_URL/$IMAGE_NAME:$(git rev-parse --short HEAD) 28 | -t $REPOSITORY_URL/$IMAGE_NAME:latest . 29 | - name: Docker Login 30 | uses: docker/login-action@v2 31 | with: 32 | registry: ${{ env.REPOSITORY_URL }} 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | - name: Publish Image 36 | id: docker-push 37 | run: docker push --all-tags $REPOSITORY_URL/$IMAGE_NAME 38 | 39 | - uses: act10ns/slack@v2 40 | with: 41 | status: ${{ job.status }} 42 | steps: ${{ toJson(steps) }} 43 | if: failure() 44 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_shelve.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import action_progressbar, build_query 4 | 5 | 6 | @click.command('shelve', short_help='Shelve alerts') 7 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--timeout', metavar='SECONDS', type=int, help='Seconds before alert auto-unshelved.', default=7200, show_default=True) 11 | @click.option('--text', help='Message associated with status change') 12 | @click.pass_obj 13 | def cli(obj, ids, query, filters, timeout, text): 14 | """Set alert status to 'shelved'.""" 15 | client = obj['client'] 16 | if ids: 17 | total = len(ids) 18 | else: 19 | if query: 20 | query = [('q', query)] 21 | else: 22 | query = build_query(filters) 23 | total, _, _ = client.get_count(query) 24 | ids = [a.id for a in client.get_alerts(query)] 25 | 26 | action_progressbar(client, action='shelve', ids=ids, 27 | label=f'Shelving {total} alerts', text=text, timeout=timeout) 28 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_key.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import datetime, timedelta 3 | 4 | import click 5 | 6 | 7 | @click.command('key', short_help='Create API key') 8 | @click.option('--api-key', '-K', help='User-defined API Key. [default: random string]') 9 | @click.option('--username', '-u', help='User (Admin only)') 10 | @click.option('--scope', 'scopes', multiple=True, help='List of permissions eg. admin:keys, write:alerts') 11 | @click.option('--duration', metavar='SECONDS', type=int, help='Duration API key is valid') 12 | @click.option('--text', help='Description of API key use') 13 | @click.option('--customer', metavar='STRING', help='Customer') 14 | @click.option('--delete', '-D', metavar='ID', help='Delete API key using ID or KEY') 15 | @click.pass_obj 16 | def cli(obj, api_key, username, scopes, duration, text, customer, delete): 17 | """Create or delete an API key.""" 18 | client = obj['client'] 19 | if delete: 20 | client.delete_key(delete) 21 | else: 22 | try: 23 | expires = datetime.utcnow() + timedelta(seconds=duration) if duration else None 24 | key = client.create_key(username, scopes, expires, text, customer, key=api_key) 25 | except Exception as e: 26 | click.echo(f'ERROR: {e}', err=True) 27 | sys.exit(1) 28 | click.echo(key.key) 29 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_watch.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | import click 5 | 6 | from .cmd_query import cli as query_cmd 7 | 8 | 9 | @click.command('watch', short_help='Watch alerts') 10 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 11 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 12 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 13 | @click.option('--details', is_flag=True, help='Compact output with details') 14 | @click.option('--interval', '-n', metavar='SECONDS', type=int, default=2, help='Refresh interval') 15 | @click.pass_context 16 | def cli(ctx, ids, query, filters, details, interval): 17 | """Watch for new alerts.""" 18 | if details: 19 | display = 'details' 20 | else: 21 | display = 'compact' 22 | from_date = None 23 | 24 | auto_refresh = True 25 | while auto_refresh: 26 | try: 27 | auto_refresh, from_date = ctx.invoke(query_cmd, ids=ids, query=query, 28 | filters=filters, display=display, from_date=from_date) 29 | time.sleep(interval) 30 | except (KeyboardInterrupt, SystemExit) as e: 31 | sys.exit(e) 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-autopep8 3 | rev: v1.5.1 4 | hooks: 5 | - id: autopep8 6 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 7 | rev: v2.5.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-ast 11 | - id: check-byte-order-marker 12 | - id: check-case-conflict 13 | - id: check-docstring-first 14 | - id: check-json 15 | - id: check-merge-conflict 16 | - id: check-yaml 17 | - id: debug-statements 18 | - id: double-quote-string-fixer 19 | - id: end-of-file-fixer 20 | - id: fix-encoding-pragma 21 | args: ['--remove'] 22 | - id: pretty-format-json 23 | args: ['--autofix'] 24 | - id: name-tests-test 25 | args: ['--django'] 26 | - id: requirements-txt-fixer 27 | - id: trailing-whitespace 28 | - repo: https://github.com/pycqa/flake8 29 | rev: 3.9.2 30 | hooks: 31 | - id: flake8 32 | - repo: https://github.com/asottile/pyupgrade 33 | rev: v1.27.0 34 | hooks: 35 | - id: pyupgrade 36 | args: ['--py3-plus'] 37 | - repo: https://github.com/asottile/seed-isort-config 38 | rev: v1.9.4 39 | hooks: 40 | - id: seed-isort-config 41 | - repo: https://github.com/pre-commit/mirrors-isort 42 | rev: v4.3.21 43 | hooks: 44 | - id: isort 45 | -------------------------------------------------------------------------------- /tests/integration/test_keys.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | from alertaclient.models.enums import Scope 5 | 6 | 7 | class AlertTestCase(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 11 | 12 | def test_key(self): 13 | api_key = self.client.create_key( 14 | username='key@alerta.io', scopes=[Scope.write_alerts, Scope.admin_keys], text='Ops API Key' 15 | ) 16 | api_key_id = api_key.id 17 | 18 | self.assertEqual(api_key.user, 'key@alerta.io') 19 | self.assertEqual(sorted(api_key.scopes), sorted(['write:alerts', 'admin:keys'])) 20 | 21 | api_key = self.client.update_key(api_key_id, scopes=[Scope.write_alerts, Scope.write_heartbeats, Scope.admin_keys], text='Updated Ops API Key') 22 | self.assertEqual(sorted(api_key.scopes), sorted([Scope.write_alerts, Scope.write_heartbeats, Scope.admin_keys])) 23 | self.assertEqual(api_key.text, 'Updated Ops API Key') 24 | 25 | api_key = self.client.create_key( 26 | username='key@alerta.io', scopes=[Scope.admin], text='Admin API Key', key='admin-key' 27 | ) 28 | self.assertEqual(api_key.key, 'admin-key') 29 | 30 | api_keys = self.client.get_keys(query=[('user', 'key@alerta.io')]) 31 | self.assertEqual(len(api_keys), 2) 32 | 33 | api_keys = self.client.delete_key(api_key_id) 34 | self.assertEqual(len(api_keys), 1) 35 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_blackouts.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('blackouts', short_help='List alert suppressions') 8 | @click.option('--purge', is_flag=True, help='Delete all expired blackouts') 9 | @click.pass_obj 10 | def cli(obj, purge): 11 | """List alert suppressions.""" 12 | client = obj['client'] 13 | 14 | if obj['output'] == 'json': 15 | r = client.http.get('/blackouts') 16 | click.echo(json.dumps(r['blackouts'], sort_keys=True, indent=4, ensure_ascii=False)) 17 | else: 18 | timezone = obj['timezone'] 19 | headers = { 20 | 'id': 'ID', 'priority': 'P', 'environment': 'ENVIRONMENT', 'service': 'SERVICE', 'resource': 'RESOURCE', 21 | 'event': 'EVENT', 'group': 'GROUP', 'tags': 'TAGS', 'origin': 'ORIGIN', 'customer': 'CUSTOMER', 22 | 'startTime': 'START', 'endTime': 'END', 'duration': 'DURATION', 'user': 'USER', 23 | 'createTime': 'CREATED', 'text': 'COMMENT', 'status': 'STATUS', 'remaining': 'REMAINING' 24 | } 25 | blackouts = client.get_blackouts() 26 | click.echo(tabulate([b.tabular(timezone) for b in blackouts], headers=headers, tablefmt=obj['output'])) 27 | 28 | expired = [b for b in blackouts if b.status == 'expired'] 29 | if purge: 30 | with click.progressbar(expired, label=f'Purging {len(expired)} blackouts') as bar: 31 | for b in bar: 32 | client.delete_blackout(b.id) 33 | -------------------------------------------------------------------------------- /tests/unit/test_heartbeats.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class HeartbeatTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.heartbeat = """ 14 | { 15 | "heartbeat": { 16 | "createTime": "2017-10-02T23:54:05.214Z", 17 | "customer": null, 18 | "href": "http://localhost:8080/heartbeat/4a0b87cd-9786-48f8-9994-59a9209ff0b2", 19 | "id": "4a0b87cd-9786-48f8-9994-59a9209ff0b2", 20 | "latency": 0.0, 21 | "origin": "app/web01", 22 | "receiveTime": "2017-10-02T23:54:05.214Z", 23 | "since": 0, 24 | "status": "ok", 25 | "tags": [ 26 | "london", 27 | "linux" 28 | ], 29 | "timeout": 10, 30 | "type": "Heartbeat" 31 | }, 32 | "id": "4a0b87cd-9786-48f8-9994-59a9209ff0b2", 33 | "status": "ok" 34 | } 35 | """ 36 | 37 | @requests_mock.mock() 38 | def test_heartbeat(self, m): 39 | m.post('http://localhost:8080/heartbeat', text=self.heartbeat) 40 | hb = self.client.heartbeat(origin='app/web01', timeout=10, tags=['london', 'linux']) 41 | self.assertEqual(hb.origin, 'app/web01') 42 | self.assertEqual(hb.event_type, 'Heartbeat') 43 | self.assertEqual(hb.timeout, 10) 44 | self.assertIn('linux', hb.tags) 45 | -------------------------------------------------------------------------------- /tests/unit/test_keys.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class ApiKeyTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.key = """ 14 | { 15 | "data": { 16 | "count": 0, 17 | "customer": null, 18 | "expireTime": "2018-10-03T08:36:14.651Z", 19 | "href": "http://localhost:8080/key/BpSG0Ck5JCqk5TJiuBSLAWuTs03QKc_527T5cDtw", 20 | "id": "f4203347-d1b2-4f56-b5e9-6de97cf2d8ae", 21 | "key": "BpSG0Ck5JCqk5TJiuBSLAWuTs03QKc_527T5cDtw", 22 | "lastUsedTime": null, 23 | "scopes": [ 24 | "write:alerts", 25 | "admin:keys" 26 | ], 27 | "text": "Ops kAPI Key", 28 | "type": "read-write", 29 | "user": "johndoe@example.com" 30 | }, 31 | "key": "demo-key", 32 | "status": "ok" 33 | } 34 | """ 35 | 36 | @requests_mock.mock() 37 | def test_key(self, m): 38 | m.post('http://localhost:8080/key', text=self.key) 39 | api_key = self.client.create_key(username='johndoe@example.com', scopes=['write:alerts', 'admin:keys'], 40 | text='Ops API Key', key='demo-key') 41 | self.assertEqual(api_key.user, 'johndoe@example.com') 42 | self.assertEqual(sorted(api_key.scopes), sorted(['write:alerts', 'admin:keys'])) 43 | -------------------------------------------------------------------------------- /alertaclient/models/group.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from uuid import uuid4 3 | 4 | JSON = Dict[str, Any] 5 | 6 | 7 | # class GroupUser: 8 | # 9 | # def __init__(self, id: str, login: str, name: str, status: str) -> None: 10 | # self.id = id 11 | # self.login = login 12 | # self.name = name 13 | # self.status = status 14 | # 15 | 16 | # class GroupUsers: 17 | # 18 | # def __init__(self, id: str, users: List[GroupUser]) -> None: 19 | # self.id = id 20 | # self.users = users 21 | 22 | 23 | class Group: 24 | """ 25 | Group model. 26 | """ 27 | 28 | def __init__(self, name: str, text: str, **kwargs) -> None: 29 | if not name: 30 | raise ValueError('Missing mandatory value for name') 31 | 32 | self.id = kwargs.get('id', str(uuid4())) 33 | self.name = name 34 | self.text = text or '' 35 | self.count = kwargs.get('count') 36 | 37 | def __repr__(self) -> str: 38 | return 'Group(id={!r}, name={!r}, text={!r}, count={!r})'.format( 39 | self.id, self.name, self.text, self.count) 40 | 41 | @classmethod 42 | def parse(cls, json: JSON) -> 'Group': 43 | return Group( 44 | id=json.get('id', None), 45 | name=json.get('name', None), 46 | text=json.get('text', None), 47 | count=json.get('count', 0) 48 | ) 49 | 50 | def tabular(self): 51 | return { 52 | 'id': self.id, 53 | 'name': self.name, 54 | 'text': self.text, 55 | 'count': self.count 56 | } 57 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_history.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | from alertaclient.utils import build_query 7 | 8 | 9 | @click.command('history', short_help='Show alert history') 10 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 11 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 12 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 13 | @click.pass_obj 14 | def cli(obj, ids, query, filters): 15 | """Show status and severity changes for alerts.""" 16 | client = obj['client'] 17 | 18 | if obj['output'] == 'json': 19 | r = client.http.get('/alerts/history') 20 | click.echo(json.dumps(r['history'], sort_keys=True, indent=4, ensure_ascii=False)) 21 | else: 22 | timezone = obj['timezone'] 23 | if ids: 24 | query = [('id', x) for x in ids] 25 | elif query: 26 | query = [('q', query)] 27 | else: 28 | query = build_query(filters) 29 | alerts = client.get_history(query) 30 | 31 | headers = {'id': 'ID', 'updateTime': 'LAST UPDATED', 'severity': 'SEVERITY', 'status': 'STATUS', 32 | 'type': 'TYPE', 'customer': 'CUSTOMER', 'environment': 'ENVIRONMENT', 'service': 'SERVICE', 33 | 'resource': 'RESOURCE', 'group': 'GROUP', 'event': 'EVENT', 'value': 'VALUE', 'text': 'TEXT'} 34 | click.echo( 35 | tabulate([a.tabular(timezone) for a in alerts], headers=headers, tablefmt=obj['output'])) 36 | -------------------------------------------------------------------------------- /tests/unit/test_notes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class NotesTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.note = """ 14 | { 15 | "id": "62b62c6c-fca3-4329-b517-fc47c2371e63", 16 | "note": { 17 | "attributes": { 18 | "environment": "Production", 19 | "event": "node_down", 20 | "resource": "web01", 21 | "severity": "major", 22 | "status": "open" 23 | }, 24 | "createTime": "2020-04-19T10:45:49.385Z", 25 | "customer": null, 26 | "href": "http://localhost:8080/note/62b62c6c-fca3-4329-b517-fc47c2371e63", 27 | "id": "62b62c6c-fca3-4329-b517-fc47c2371e63", 28 | "related": { 29 | "alert": "e7020428-5dad-4a41-9bfe-78e9d55cda06" 30 | }, 31 | "text": "this is a new note", 32 | "type": "alert", 33 | "updateTime": null, 34 | "user": null 35 | }, 36 | "status": "ok" 37 | } 38 | """ 39 | 40 | @requests_mock.mock() 41 | def test_add_note(self, m): 42 | m.put('http://localhost:8080/alert/e7020428-5dad-4a41-9bfe-78e9d55cda06/note', text=self.note) 43 | note = self.client.alert_note(id='e7020428-5dad-4a41-9bfe-78e9d55cda06', text='this is a new note') 44 | self.assertEqual(note.text, 'this is a new note') 45 | -------------------------------------------------------------------------------- /tests/integration/test_blackouts.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_blackout(self): 12 | blackout = self.client.create_blackout( 13 | environment='Production', service=['Web', 'App'], resource='web01', event='node_down', group='Network', 14 | tags=['london', 'linux'], origin='foo/bar' 15 | ) 16 | blackout_id = blackout.id 17 | 18 | self.assertEqual(blackout.environment, 'Production') 19 | self.assertEqual(blackout.service, ['Web', 'App']) 20 | self.assertIn('london', blackout.tags) 21 | self.assertIn('linux', blackout.tags) 22 | self.assertEqual(blackout.origin, 'foo/bar') 23 | 24 | blackout = self.client.update_blackout(blackout_id, environment='Development', group='Network', 25 | origin='foo/quux', text='updated blackout') 26 | self.assertEqual(blackout.environment, 'Development') 27 | self.assertEqual(blackout.group, 'Network') 28 | self.assertEqual(blackout.origin, 'foo/quux') 29 | self.assertEqual(blackout.text, 'updated blackout') 30 | 31 | blackout = self.client.create_blackout( 32 | environment='Production', service=['Core'], group='Network', origin='foo/baz' 33 | ) 34 | 35 | blackouts = self.client.get_blackouts() 36 | self.assertEqual(len(blackouts), 2) 37 | 38 | self.client.delete_blackout(blackout_id) 39 | 40 | blackouts = self.client.get_blackouts() 41 | self.assertEqual(len(blackouts), 1) 42 | -------------------------------------------------------------------------------- /tests/integration/test_alerts.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_alert(self): 12 | id, alert, message = self.client.send_alert( 13 | environment='Production', resource='web01', event='node_down', correlated=['node_up', 'node_down'], 14 | service=['Web', 'App'], severity='critical', tags=['london', 'linux'], value=4 15 | ) 16 | self.assertEqual(alert.value, '4') # values cast to string 17 | self.assertEqual(alert.timeout, 86400) # timeout returned as int 18 | self.assertIn('london', alert.tags) 19 | 20 | def test_alert_notes(self): 21 | alert_id, alert, message = self.client.send_alert( 22 | environment='Production', resource='web02', event='node_down', correlated=['node_up', 'node_down'], 23 | service=['Web', 'App'], severity='critical', tags=['london', 'linux'], value=4 24 | ) 25 | note = self.client.alert_note(alert_id, text='this is a test note') 26 | self.assertEqual(note.text, 'this is a test note') 27 | 28 | notes = self.client.get_alert_notes(alert_id) 29 | self.assertEqual(notes[0].text, 'this is a test note') 30 | self.assertEqual(notes[0].user, 'admin@alerta.io') 31 | 32 | note = self.client.update_alert_note(alert_id, notes[0].id, text='updated note text') 33 | self.assertEqual(note.text, 'updated note text') 34 | 35 | self.client.delete_alert_note(alert_id, notes[0].id) 36 | 37 | notes = self.client.get_alert_notes(alert_id) 38 | self.assertEqual(notes, []) 39 | -------------------------------------------------------------------------------- /tests/unit/test_users.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class UserTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.user = """ 14 | { 15 | "domains": [ 16 | "alerta.io", 17 | "gmail.com", 18 | "foo.com" 19 | ], 20 | "status": "ok", 21 | "total": 1, 22 | "users": [ 23 | { 24 | "attributes": {}, 25 | "createTime": "2017-10-01T15:45:32.671Z", 26 | "domain": "alerta.io", 27 | "email": "admin@alerta.io", 28 | "email_verified": false, 29 | "href": "http://localhost:8080/user/107dcbe2-e5a9-4f6a-8c23-ce9379288bf5", 30 | "id": "107dcbe2-e5a9-4f6a-8c23-ce9379288bf5", 31 | "lastLogin": "2017-10-01T18:30:32.850Z", 32 | "name": "Admin", 33 | "provider": "basic", 34 | "roles": [ 35 | "admin" 36 | ], 37 | "status": "active", 38 | "text": "", 39 | "updateTime": "2017-10-03T09:21:20.888Z" 40 | } 41 | ] 42 | } 43 | """ 44 | 45 | @requests_mock.mock() 46 | def test_user(self, m): 47 | m.get('http://localhost:8080/users', text=self.user) 48 | users = self.client.get_users() 49 | self.assertEqual(users[0].name, 'Admin') 50 | self.assertEqual(sorted(users[0].roles), sorted(['admin'])) 51 | self.assertEqual(users[0].status, 'active') 52 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_note.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from alertaclient.utils import build_query 4 | 5 | 6 | @click.command('note', short_help='Add note') 7 | @click.option('--alert-ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 8 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 9 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 10 | @click.option('--text', help='Note or message') 11 | @click.option('--delete', '-D', metavar='ID', nargs=2, help='Delete note, using alert ID and note ID') 12 | @click.pass_obj 13 | def cli(obj, alert_ids, query, filters, text, delete): 14 | """ 15 | Add or delete note to alerts. 16 | 17 | EXAMPLES 18 | 19 | Add a note to an alert. 20 | 21 | $ alerta note --alert-ids --text 22 | 23 | List notes for an alert. 24 | 25 | $ alerta notes --alert-ids 26 | 27 | Delete a note for an alert. 28 | 29 | $ alerta note -D 30 | """ 31 | client = obj['client'] 32 | if delete: 33 | client.delete_alert_note(*delete) 34 | else: 35 | if alert_ids: 36 | total = len(alert_ids) 37 | else: 38 | if query: 39 | query = [('q', query)] 40 | else: 41 | query = build_query(filters) 42 | total, _, _ = client.get_count(query) 43 | alert_ids = [a.id for a in client.get_alerts(query)] 44 | 45 | with click.progressbar(alert_ids, label=f'Add note to {total} alerts') as bar: 46 | for id in bar: 47 | client.alert_note(id, text=text) 48 | -------------------------------------------------------------------------------- /tests/integration/test_history.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from alertaclient.api import Client 4 | 5 | 6 | class AlertTestCase(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client(endpoint='http://alerta:8080/api', key='demo-key') 10 | 11 | def test_alert(self): 12 | id, alert, message = self.client.send_alert( 13 | environment='Production', resource='net03', event='node_down', correlated=['node_up', 'node_down', 'node_marginal'], 14 | service=['Network', 'Core'], severity='critical', tags=['newyork', 'linux'], value=4 15 | ) 16 | id, alert, message = self.client.send_alert( 17 | environment='Production', resource='net03', event='node_marginal', correlated=['node_up', 'node_down', 'node_marginal'], 18 | service=['Network', 'Core'], severity='minor', tags=['newyork', 'linux'], value=1 19 | ) 20 | self.assertEqual(alert.value, '1') # values cast to string 21 | self.assertEqual(alert.timeout, 86400) # timeout returned as int 22 | self.assertIn('newyork', alert.tags) 23 | 24 | def test_history(self): 25 | hist = self.client.get_history(query=[('resource', 'net03')]) 26 | self.assertEqual(hist[0].environment, 'Production') 27 | self.assertEqual(hist[0].service, ['Network', 'Core']) 28 | self.assertEqual(hist[0].resource, 'net03') 29 | self.assertIn('newyork', hist[0].tags) 30 | self.assertEqual(hist[0].change_type, 'new') 31 | 32 | self.assertEqual(hist[1].environment, 'Production') 33 | self.assertEqual(hist[1].service, ['Network', 'Core']) 34 | self.assertEqual(hist[1].resource, 'net03') 35 | self.assertIn('newyork', hist[1].tags) 36 | self.assertEqual(hist[1].change_type, 'new') 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | import setuptools 6 | 7 | 8 | def read(filename): 9 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 10 | 11 | 12 | setuptools.setup( 13 | name='alerta', 14 | version=read('VERSION'), 15 | description='Alerta unified command-line tool and SDK', 16 | long_description=read('README.md'), 17 | long_description_content_type='text/markdown', 18 | url='https://github.com/guardian/python-alerta', 19 | license='Apache License 2.0', 20 | author='Nick Satterly', 21 | author_email='nfsatterly@gmail.com', 22 | packages=setuptools.find_packages(exclude=['tests']), 23 | install_requires=[ 24 | 'Click', 25 | 'requests', 26 | 'requests_hawk', 27 | 'tabulate', 28 | 'pytz' 29 | ], 30 | include_package_data=True, 31 | zip_safe=False, 32 | entry_points={ 33 | 'console_scripts': [ 34 | 'alerta = alertaclient.cli:cli' 35 | ] 36 | }, 37 | keywords='alerta client unified command line tool sdk', 38 | classifiers=[ 39 | 'Development Status :: 5 - Production/Stable', 40 | 'Environment :: Console', 41 | 'Intended Audience :: Information Technology', 42 | 'Intended Audience :: System Administrators', 43 | 'Intended Audience :: Telecommunications Industry', 44 | 'License :: OSI Approved :: Apache Software License', 45 | 'Programming Language :: Python :: 3.11', 46 | 'Programming Language :: Python :: 3.10', 47 | 'Programming Language :: Python :: 3.9', 48 | 'Programming Language :: Python :: 3.8', 49 | 'Topic :: System :: Monitoring', 50 | 'Topic :: Software Development :: Libraries :: Python Modules' 51 | ], 52 | python_requires='>=3.8' 53 | ) 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | build.py 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # Editors 108 | .idea 109 | .DS_Store 110 | *.swp 111 | *.BAK 112 | *.log* 113 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | env: 9 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 10 | REPOSITORY_URL: docker.pkg.github.com 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | python-version: ['3.8', '3.9', '3.10', '3.11'] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | id: install-deps 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install flake8 pytest 31 | pip install -r requirements.txt 32 | pip install -r requirements-dev.txt 33 | pip install . 34 | - name: Pre-commit hooks 35 | id: hooks 36 | run: | 37 | pre-commit run -a --show-diff-on-failure 38 | - name: Lint with flake8 39 | id: lint 40 | run: | 41 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 42 | flake8 . --count --exit-zero --max-complexity=50 --max-line-length=127 --statistics 43 | - name: Test with pytest 44 | id: unit-test 45 | run: | 46 | pytest --cov=alertaclient tests/unit 47 | - name: Integration Test 48 | id: integration-test 49 | run: | 50 | docker-compose -f docker-compose.ci.yaml build sut 51 | docker-compose -f docker-compose.ci.yaml up --exit-code-from sut 52 | docker-compose -f docker-compose.ci.yaml rm --stop --force 53 | - uses: act10ns/slack@v2 54 | with: 55 | status: ${{ job.status }} 56 | steps: ${{ toJson(steps) }} 57 | if: failure() 58 | -------------------------------------------------------------------------------- /alertaclient/models/note.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from alertaclient.utils import DateTime 4 | 5 | 6 | class Note: 7 | 8 | def __init__(self, text, user, note_type, **kwargs): 9 | 10 | self.id = kwargs.get('id', None) 11 | self.text = text 12 | self.user = user 13 | self.note_type = note_type 14 | self.attributes = kwargs.get('attributes', None) or dict() 15 | self.create_time = kwargs['create_time'] if 'create_time' in kwargs else datetime.utcnow() 16 | self.update_time = kwargs.get('update_time') 17 | self.alert = kwargs.get('alert') 18 | self.customer = kwargs.get('customer') 19 | 20 | @classmethod 21 | def parse(cls, json): 22 | return Note( 23 | id=json.get('id', None), 24 | text=json.get('text', None), 25 | user=json.get('user', None), 26 | attributes=json.get('attributes', dict()), 27 | note_type=json.get('type', None), 28 | create_time=DateTime.parse(json['createTime']) if 'createTime' in json else None, 29 | update_time=DateTime.parse(json['updateTime']) if 'updateTime' in json else None, 30 | alert=json.get('related', {}).get('alert'), 31 | customer=json.get('customer', None) 32 | ) 33 | 34 | def __repr__(self): 35 | return 'Note(id={!r}, text={!r}, user={!r}, type={!r}, customer={!r})'.format( 36 | self.id, self.text, self.user, self.note_type, self.customer 37 | ) 38 | 39 | def tabular(self, timezone=None): 40 | note = { 41 | 'id': self.id, 42 | 'text': self.text, 43 | 'createTime': DateTime.localtime(self.create_time, timezone), 44 | 'user': self.user, 45 | # 'attributes': self.attributes, 46 | 'type': self.note_type, 47 | 'related': self.alert, 48 | 'updateTime': DateTime.localtime(self.update_time, timezone), 49 | 'customer': self.customer 50 | } 51 | return note 52 | -------------------------------------------------------------------------------- /tests/unit/test_history.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class HistoryTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.history = """ 14 | { 15 | "history": [ 16 | { 17 | "attributes": { 18 | "ip": "127.0.0.1", 19 | "notify": false 20 | }, 21 | "customer": null, 22 | "environment": "Production", 23 | "event": "node_down", 24 | "group": "Misc", 25 | "href": "http://localhost:8080/alert/e7020428-5dad-4a41-9bfe-78e9d55cda06", 26 | "id": "e7020428-5dad-4a41-9bfe-78e9d55cda06", 27 | "origin": "alertad/fdaa33ca.local", 28 | "resource": "web01", 29 | "service": [ 30 | "Web", 31 | "App" 32 | ], 33 | "severity": "critical", 34 | "tags": [ 35 | "london", 36 | "linux" 37 | ], 38 | "text": "", 39 | "type": "severity", 40 | "updateTime": "2017-10-03T09:12:27.283Z", 41 | "value": "4" 42 | } 43 | ], 44 | "status": "ok", 45 | "total": 1 46 | } 47 | """ 48 | 49 | @requests_mock.mock() 50 | def test_history(self, m): 51 | m.get('http://localhost:8080/alerts/history', text=self.history) 52 | hist = self.client.get_history() 53 | self.assertEqual(hist[0].environment, 'Production') 54 | self.assertEqual(hist[0].service, ['Web', 'App']) 55 | self.assertEqual(hist[0].resource, 'web01') 56 | self.assertIn('london', hist[0].tags) 57 | self.assertEqual(hist[0].change_type, 'severity') 58 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_login.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | from alertaclient.auth import azure, github, gitlab, google, oidc 6 | from alertaclient.auth.token import Jwt 7 | from alertaclient.auth.utils import save_token 8 | from alertaclient.exceptions import AuthError 9 | 10 | 11 | @click.command('login', short_help='Login with user credentials') 12 | @click.argument('username', required=False) 13 | @click.pass_obj 14 | def cli(obj, username): 15 | """Authenticate using Azure, Github, Gitlab, Google OAuth2, OpenID or 16 | Basic Auth username/password instead of using an API key.""" 17 | client = obj['client'] 18 | provider = obj['provider'] 19 | client_id = obj['client_id'] 20 | 21 | try: 22 | if provider == 'azure': 23 | token = azure.login(client, obj['azure_tenant'], client_id)['token'] 24 | elif provider == 'github': 25 | token = github.login(client, obj['github_url'], client_id)['token'] 26 | elif provider == 'gitlab': 27 | token = gitlab.login(client, obj['gitlab_url'], client_id)['token'] 28 | elif provider == 'google': 29 | if not username: 30 | username = click.prompt('Email') 31 | token = google.login(client, username, client_id)['token'] 32 | elif provider == 'openid': 33 | token = oidc.login(client, obj['oidc_auth_url'], client_id)['token'] 34 | elif provider == 'basic' or provider == 'ldap': 35 | if not username: 36 | username = click.prompt('Email') 37 | password = click.prompt('Password', hide_input=True) 38 | token = client.login(username, password)['token'] 39 | else: 40 | click.echo(f'ERROR: unknown provider {provider}', err=True) 41 | sys.exit(1) 42 | except Exception as e: 43 | raise AuthError(e) 44 | 45 | jwt = Jwt() 46 | preferred_username = jwt.parse(token)['preferred_username'] 47 | if preferred_username: 48 | save_token(client.endpoint, preferred_username, token) 49 | click.echo(f'Logged in as {preferred_username}') 50 | else: 51 | click.echo('Failed to login.') 52 | sys.exit(1) 53 | -------------------------------------------------------------------------------- /alertaclient/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import os 4 | import platform 5 | import sys 6 | 7 | import click 8 | import pytz 9 | 10 | 11 | class CustomJsonEncoder(json.JSONEncoder): 12 | def default(self, o): # pylint: disable=method-hidden 13 | if isinstance(o, (datetime.date, datetime.datetime)): 14 | return o.replace(microsecond=0).strftime('%Y-%m-%dT%H:%M:%S') + '.%03dZ' % (o.microsecond // 1000) 15 | elif isinstance(o, datetime.timedelta): 16 | return int(o.total_seconds()) 17 | else: 18 | return json.JSONEncoder.default(self, o) 19 | 20 | 21 | class DateTime: 22 | @staticmethod 23 | def parse(date_str): 24 | if not isinstance(date_str, str): 25 | return 26 | try: 27 | return datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S.%fZ') 28 | except Exception: 29 | raise ValueError('dates must be ISO 8601 date format YYYY-MM-DDThh:mm:ss.sssZ') 30 | 31 | @staticmethod 32 | def iso8601(dt): 33 | return dt.replace(microsecond=0).strftime('%Y-%m-%dT%H:%M:%S') + '.%03dZ' % (dt.microsecond // 1000) 34 | 35 | @staticmethod 36 | def localtime(dt, timezone=None, fmt='%Y/%m/%d %H:%M:%S'): 37 | tz = pytz.timezone(timezone) 38 | try: 39 | return dt.replace(tzinfo=pytz.UTC).astimezone(tz).strftime(fmt) 40 | except AttributeError: 41 | return 42 | 43 | 44 | def build_query(filters): 45 | return [tuple(f.split('=', 1)) for f in filters if '=' in f] 46 | 47 | 48 | def action_progressbar(client, action, ids, label, text=None, timeout=None): 49 | skipped = 0 50 | 51 | def show_skipped(id): 52 | if not id and skipped: 53 | return f'(skipped {skipped})' 54 | 55 | with click.progressbar(ids, label=label, show_eta=True, item_show_func=show_skipped) as bar: 56 | for id in bar: 57 | try: 58 | client.action(id, action=action, text=text, timeout=timeout) 59 | except Exception: 60 | skipped += 1 61 | 62 | 63 | def origin(): 64 | prog = os.path.basename(sys.argv[0]) 65 | return f'{prog}/{platform.uname()[1]}' 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: [ 'v*' ] 6 | 7 | env: 8 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 3.11 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.11 21 | - name: Install dependencies 22 | id: install-deps 23 | run: | 24 | python3 -m pip install --upgrade pip 25 | pip install flake8 pytest 26 | pip install -r requirements.txt 27 | pip install -r requirements-dev.txt 28 | pip install . 29 | - name: Pre-commit hooks 30 | id: hooks 31 | run: | 32 | pre-commit run -a --show-diff-on-failure 33 | - name: Test with pytest 34 | id: test 35 | run: | 36 | pytest --cov=alertaclient tests/unit 37 | - uses: act10ns/slack@v2 38 | with: 39 | status: ${{ job.status }} 40 | steps: ${{ toJson(steps) }} 41 | if: failure() 42 | 43 | release: 44 | name: Publish 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - name: Set up Python 3.11 51 | uses: actions/setup-python@v5 52 | with: 53 | python-version: 3.11 54 | - name: Build 55 | id: build 56 | run: | 57 | python3 -m pip install --upgrade build 58 | python3 -m build 59 | - name: Publish to PyPI 60 | id: publish 61 | env: 62 | TWINE_USERNAME: __token__ 63 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 64 | run: | 65 | python3 -m pip install --upgrade twine 66 | python3 -m twine check dist/* 67 | python3 -m twine upload --verbose dist/* 68 | 69 | - uses: act10ns/slack@v2 70 | with: 71 | status: ${{ job.status }} 72 | steps: ${{ toJson(steps) }} 73 | 74 | - name: Test Install 75 | run: | 76 | python3 -m pip install --upgrade alerta 77 | python3 -m pip freeze 78 | -------------------------------------------------------------------------------- /tests/unit/test_http_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import HTTPClient 6 | 7 | 8 | class HttpClientTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.http = HTTPClient(endpoint='https://httpbin.org') 12 | 13 | @requests_mock.mock() 14 | def test_query_string(self, m): 15 | 16 | m.get('https://httpbin.org/get', text='{}') 17 | m.post('https://httpbin.org/post', text='{}') 18 | m.put('https://httpbin.org/put', text='{}') 19 | m.delete('https://httpbin.org/delete', text='{}') 20 | 21 | self.http.get(path='/get', query=None) 22 | self.http.get(path='/get', query=(('foo', 'bar'), ('quxx', 'baz'))) 23 | self.http.get(path='/get', query=None, page=None) 24 | self.http.get(path='/get', query=None, page=2, page_size=None) 25 | self.http.get(path='/get', query=None, page=None, page_size=None) 26 | self.http.get(path='/get', query=None, page=2, page_size=100) 27 | self.http.get(path='/get', query=None, page_size=20) 28 | self.http.get(path='/get', query=None, page=None, page_size=200) 29 | 30 | history = m.request_history 31 | self.assertEqual(history[0].url, 'https://httpbin.org/get') 32 | self.assertEqual(history[1].url, 'https://httpbin.org/get?foo=bar&quxx=baz') 33 | self.assertEqual(history[2].url, 'https://httpbin.org/get?page=1') 34 | self.assertEqual(history[3].url, 'https://httpbin.org/get?page=2&page-size=50') 35 | self.assertEqual(history[4].url, 'https://httpbin.org/get?page=1&page-size=50') 36 | self.assertEqual(history[5].url, 'https://httpbin.org/get?page=2&page-size=100') 37 | self.assertEqual(history[6].url, 'https://httpbin.org/get?page-size=20') 38 | self.assertEqual(history[7].url, 'https://httpbin.org/get?page=1&page-size=200') 39 | 40 | self.http.post(path='/post', data='{}') 41 | self.assertEqual(history[8].url, 'https://httpbin.org/post') 42 | 43 | self.http.put(path='/put', data='{}') 44 | self.assertEqual(history[9].url, 'https://httpbin.org/put') 45 | 46 | self.http.delete(path='/delete') 47 | self.assertEqual(history[10].url, 'https://httpbin.org/delete') 48 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_group.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('group', short_help='Create user group') 8 | @click.option('--id', '-i', metavar='ID', help='Group ID') 9 | @click.option('--name', help='Group name') 10 | @click.option('--text', help='Description of user group') 11 | @click.option('--user', '-U', help='Add user to group') 12 | @click.option('--users', is_flag=True, metavar='ID', help='Get list of group users') 13 | @click.option('--delete', '-D', metavar='ID', help='Delete user group, or remove user from group') 14 | @click.pass_obj 15 | def cli(obj, id, name, text, user, users, delete): 16 | """ 17 | Create or delete a user group, add and remove users from groups. 18 | 19 | EXAMPLES 20 | 21 | Create a group. 22 | 23 | $ alerta group --name --text 24 | 25 | Add a user to a group. 26 | 27 | $ alerta group -id --user 28 | 29 | List users in a group. 30 | 31 | $ alerta group --users 32 | 33 | Delete a user from a group. 34 | 35 | $ alerta group -id -D 36 | 37 | Delete a group. 38 | 39 | $ alerta group -D 40 | """ 41 | client = obj['client'] 42 | if id and user: 43 | client.add_user_to_group(id, user) 44 | elif id and delete: 45 | client.remove_user_from_group(id, delete) 46 | elif users: 47 | group_users = client.get_group_users(id) 48 | timezone = obj['timezone'] 49 | headers = {'id': 'ID', 'name': 'USER', 'email': 'EMAIL', 'roles': 'ROLES', 'status': 'STATUS', 50 | 'text': 'TEXT', 'createTime': 'CREATED', 'updateTime': 'LAST UPDATED', 51 | 'lastLogin': 'LAST LOGIN', 'email_verified': 'VERIFIED'} 52 | click.echo(tabulate([gu.tabular(timezone) for gu in group_users], headers=headers, tablefmt=obj['output'])) 53 | elif delete: 54 | client.delete_group(delete) 55 | else: 56 | try: 57 | group = client.create_group(name, text) 58 | except Exception as e: 59 | click.echo(f'ERROR: {e}', err=True) 60 | sys.exit(1) 61 | click.echo(group.id) 62 | -------------------------------------------------------------------------------- /alertaclient/models/key.py: -------------------------------------------------------------------------------- 1 | from alertaclient.utils import DateTime 2 | 3 | 4 | class ApiKey: 5 | 6 | def __init__(self, user, scopes, text='', expire_time=None, customer=None, **kwargs): 7 | self.id = kwargs.get('id', None) 8 | self.key = kwargs.get('key', None) 9 | self.user = user 10 | self.scopes = scopes 11 | self.text = text 12 | self.expire_time = expire_time 13 | self.count = kwargs.get('count', 0) 14 | self.last_used_time = kwargs.get('last_used_time', None) 15 | self.customer = customer 16 | 17 | @property 18 | def type(self): 19 | return self.scopes_to_type(self.scopes) 20 | 21 | def __repr__(self): 22 | return 'ApiKey(key={!r}, user={!r}, scopes={!r}, expireTime={!r}, customer={!r})'.format( 23 | self.key, self.user, self.scopes, self.expire_time, self.customer) 24 | 25 | @classmethod 26 | def parse(cls, json): 27 | if not isinstance(json.get('scopes', []), list): 28 | raise ValueError('scopes must be a list') 29 | 30 | return ApiKey( 31 | id=json.get('id', None), 32 | key=json.get('key', None), 33 | user=json.get('user', None), 34 | scopes=json.get('scopes', None) or list(), 35 | text=json.get('text', None), 36 | expire_time=DateTime.parse(json.get('expireTime')), 37 | count=json.get('count', None), 38 | last_used_time=DateTime.parse(json.get('lastUsedTime')), 39 | customer=json.get('customer', None) 40 | ) 41 | 42 | def scopes_to_type(self, scopes): 43 | for scope in scopes: 44 | if scope.startswith('write') or scope.startswith('admin'): 45 | return 'read-write' 46 | return 'read-only' 47 | 48 | def tabular(self, timezone=None): 49 | return { 50 | 'id': self.id, 51 | 'key': self.key, 52 | 'user': self.user, 53 | 'scopes': ','.join(self.scopes), 54 | 'text': self.text, 55 | 'expireTime': DateTime.localtime(self.expire_time, timezone), 56 | 'count': self.count, 57 | 'lastUsedTime': DateTime.localtime(self.last_used_time, timezone), 58 | 'customer': self.customer 59 | } 60 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_blackout.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | 6 | @click.command('blackout', short_help='Suppress alerts') 7 | @click.option('--environment', '-E', metavar='ENVIRONMENT', help='Environment eg. Production, Development') 8 | @click.option('--service', '-S', metavar='SERVICE', multiple=True, help='List of affected services eg. app name, Web, Network, Storage, Database, Security') 9 | @click.option('--resource', '-r', metavar='RESOURCE', help='Resource under alarm') 10 | @click.option('--event', '-e', metavar='EVENT', help='Event name') 11 | @click.option('--group', '-g', metavar='GROUP', help='Group event by type eg. OS, Performance') 12 | @click.option('--tag', '-T', 'tags', multiple=True, metavar='TAG', help='List of tags eg. London, os:linux, AWS/EC2') 13 | @click.option('--origin', '-O', metavar='ORIGIN', help='Origin of alert in form app/host') 14 | @click.option('--customer', metavar='STRING', help='Customer (Admin only)') 15 | @click.option('--start', metavar='DATETIME', help='Start time in ISO8601 eg. 2018-02-01T12:00:00.000Z') 16 | @click.option('--duration', metavar='SECONDS', type=int, help='Blackout period in seconds') 17 | @click.option('--text', help='Reason for blackout') 18 | @click.option('--delete', '-D', metavar='ID', help='Delete blackout using ID') 19 | @click.pass_obj 20 | def cli(obj, environment, service, resource, event, group, tags, origin, customer, start, duration, text, delete): 21 | """Suppress alerts for specified duration based on alert attributes.""" 22 | client = obj['client'] 23 | if delete: 24 | client.delete_blackout(delete) 25 | else: 26 | if not environment: 27 | raise click.UsageError('Missing option "--environment" / "-E".') 28 | try: 29 | blackout = client.create_blackout( 30 | environment=environment, 31 | service=service, 32 | resource=resource, 33 | event=event, 34 | group=group, 35 | tags=tags, 36 | origin=origin, 37 | customer=customer, 38 | start=start, 39 | duration=duration, 40 | text=text 41 | ) 42 | except Exception as e: 43 | click.echo(f'ERROR: {e}', err=True) 44 | sys.exit(1) 45 | click.echo(blackout.id) 46 | -------------------------------------------------------------------------------- /alertaclient/auth/token.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from http.server import BaseHTTPRequestHandler, HTTPServer 4 | from urllib.parse import parse_qs, urlparse 5 | 6 | from alertaclient.exceptions import AuthError 7 | 8 | SUCCESS_MESSAGE = """ 9 | 11 | 12 | 13 | 14 | Alerta Login 15 | 16 | 17 |

Authorization success!

18 |

Please close the web browser and return to the terminal.

19 | 20 | 21 | """ 22 | 23 | 24 | class HTTPServerHandler(BaseHTTPRequestHandler): 25 | 26 | def __init__(self, request, address, server, xsrf_token): 27 | self.xsrf_token = xsrf_token 28 | self.access_token = None 29 | BaseHTTPRequestHandler.__init__(self, request, address, server) 30 | 31 | def do_GET(self): 32 | try: 33 | qp = parse_qs(urlparse(self.path).query) 34 | except Exception as e: 35 | raise AuthError(e) 36 | 37 | if 'state' in qp and qp['state'][0] != self.xsrf_token: 38 | raise AuthError('CSRF token is invalid. Please try again.') 39 | if 'code' in qp: 40 | self.server.access_token = qp['code'][0] 41 | elif 'error' in qp: 42 | raise AuthError(qp['error']) 43 | 44 | self.send_response(200) 45 | self.send_header('Content-type', 'text/html') 46 | self.end_headers() 47 | self.wfile.write(bytes(SUCCESS_MESSAGE.encode('utf-8'))) 48 | 49 | def log_message(self, format, *args): 50 | return 51 | 52 | 53 | class TokenHandler: 54 | 55 | def get_access_token(self, xsrf_token): 56 | server_address = ('', 9004) 57 | httpd = HTTPServer(server_address, lambda request, address, 58 | server: HTTPServerHandler(request, address, server, xsrf_token)) 59 | httpd.handle_request() 60 | return httpd.access_token # pylint: disable=no-member 61 | 62 | 63 | class Jwt: 64 | 65 | def parse(self, jwt): 66 | payload = jwt.split('.')[1] 67 | if isinstance(payload, str): 68 | payload = payload.encode('ascii') 69 | padding = b'=' * (4 - (len(payload) % 4)) 70 | decoded = base64.urlsafe_b64decode(payload + padding) 71 | return json.loads(decoded.decode('utf-8')) 72 | -------------------------------------------------------------------------------- /alertaclient/auth/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from netrc import netrc 3 | from urllib.parse import urlparse 4 | 5 | from alertaclient.exceptions import ConfigurationError 6 | 7 | NETRC_FILE = os.path.join(os.path.expanduser('~'), '.netrc') 8 | 9 | 10 | def machine(endpoint): 11 | u = urlparse(endpoint) 12 | return '{}:{}'.format(u.hostname, u.port or '80') 13 | 14 | 15 | def get_token(endpoint): 16 | try: 17 | info = netrc(NETRC_FILE) 18 | except Exception: 19 | return 20 | auth = info.authenticators(machine(endpoint)) 21 | if auth is not None: 22 | _, _, password = auth 23 | return password 24 | 25 | 26 | def save_token(endpoint, username, token): 27 | with open(NETRC_FILE, 'a'): # touch file 28 | pass 29 | try: 30 | info = netrc(NETRC_FILE) 31 | except Exception as e: 32 | raise ConfigurationError(f'{e}') 33 | info.hosts[machine(endpoint)] = (username, None, token) 34 | with open(NETRC_FILE, 'w') as f: 35 | f.write(dump_netrc(info)) 36 | 37 | 38 | def clear_token(endpoint): 39 | try: 40 | info = netrc(NETRC_FILE) 41 | except Exception as e: 42 | raise ConfigurationError(f'{e}') 43 | try: 44 | del info.hosts[machine(endpoint)] 45 | with open(NETRC_FILE, 'w') as f: 46 | f.write(dump_netrc(info)) 47 | except KeyError as e: 48 | raise ConfigurationError(f'No credentials stored for {e}') 49 | 50 | 51 | # See https://bugs.python.org/issue30806 52 | def dump_netrc(self): 53 | """Dump the class data in the format of a .netrc file.""" 54 | rep = '' 55 | for host in self.hosts.keys(): 56 | attrs = self.hosts[host] 57 | rep = rep + 'machine ' + host + '\n\tlogin ' + str(attrs[0]) + '\n' 58 | if attrs[1]: 59 | rep = rep + 'account ' + str(attrs[1]) 60 | rep = rep + '\tpassword ' + str(attrs[2]) + '\n' 61 | for macro in self.macros.keys(): 62 | rep = rep + 'macdef ' + macro + '\n' 63 | for line in self.macros[macro]: 64 | rep = rep + line 65 | rep = rep + '\n' 66 | return rep 67 | 68 | 69 | def merge(dict1, dict2): 70 | """ 71 | Merge two dictionaries. 72 | :param dict1: 73 | :param dict2: 74 | :return: 75 | """ 76 | for k in dict2: 77 | if k in dict1 and isinstance(dict1[k], dict) and isinstance(dict2[k], dict): 78 | merge(dict1[k], dict2[k]) 79 | else: 80 | dict1[k] = dict2[k] 81 | -------------------------------------------------------------------------------- /tests/unit/test_blackouts.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class BlackoutTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.blackout = """ 14 | { 15 | "blackout": { 16 | "createTime": "2021-04-14T20:36:06.453Z", 17 | "customer": null, 18 | "duration": 3600, 19 | "endTime": "2021-04-14T21:36:06.453Z", 20 | "environment": "Production", 21 | "event": "node_down", 22 | "group": "Network", 23 | "href": "http://local.alerta.io:8080/blackout/5ed223a3-27dc-4c4c-97d1-504f107d8a1a", 24 | "id": "5ed223a3-27dc-4c4c-97d1-504f107d8a1a", 25 | "origin": "foo/xyz", 26 | "priority": 8, 27 | "remaining": 3600, 28 | "resource": "web01", 29 | "service": [ 30 | "Web", 31 | "App" 32 | ], 33 | "startTime": "2021-04-14T20:36:06.453Z", 34 | "status": "active", 35 | "tags": [ 36 | "london", 37 | "linux" 38 | ], 39 | "text": "Network outage in Bracknell", 40 | "user": "admin@alerta.dev" 41 | }, 42 | "id": "5ed223a3-27dc-4c4c-97d1-504f107d8a1a", 43 | "status": "ok" 44 | } 45 | """ 46 | 47 | @requests_mock.mock() 48 | def test_blackout(self, m): 49 | m.post('http://localhost:8080/blackout', text=self.blackout) 50 | alert = self.client.create_blackout(environment='Production', service=['Web', 'App'], resource='web01', 51 | event='node_down', group='Network', tags=['london', 'linux'], 52 | origin='foo/xyz', text='Network outage in Bracknell') 53 | self.assertEqual(alert.environment, 'Production') 54 | self.assertEqual(alert.service, ['Web', 'App']) 55 | self.assertEqual(alert.group, 'Network') 56 | self.assertIn('london', alert.tags) 57 | self.assertEqual(alert.origin, 'foo/xyz') 58 | self.assertEqual(alert.text, 'Network outage in Bracknell') 59 | self.assertEqual(alert.user, 'admin@alerta.dev') 60 | -------------------------------------------------------------------------------- /alertaclient/models/user.py: -------------------------------------------------------------------------------- 1 | from alertaclient.utils import DateTime 2 | 3 | 4 | class User: 5 | """ 6 | User model for BasicAuth only. 7 | """ 8 | 9 | def __init__(self, name, email, roles, text, **kwargs): 10 | self.id = kwargs.get('id', None) 11 | self.name = name 12 | self.email = email 13 | self.status = kwargs.get('status', None) or 'active' # 'active', 'inactive', 'unknown' 14 | self.roles = roles 15 | self.attributes = kwargs.get('attributes', None) or dict() 16 | self.create_time = kwargs.get('create_time', None) 17 | self.last_login = kwargs.get('last_login', None) 18 | self.text = text or '' 19 | self.update_time = kwargs.get('update_time', None) 20 | self.email_verified = kwargs.get('email_verified', False) 21 | 22 | @property 23 | def domain(self): 24 | return self.email.split('@')[1] if '@' in self.email else None 25 | 26 | def __repr__(self): 27 | return 'User(id={!r}, name={!r}, email={!r}, status={!r}, roles={!r}, email_verified={!r})'.format( 28 | self.id, self.name, self.email, self.status, ','.join(self.roles), self.email_verified 29 | ) 30 | 31 | @classmethod 32 | def parse(cls, json): 33 | return User( 34 | id=json.get('id'), 35 | name=json.get('name'), 36 | email=json.get('email', None) or json.get('login'), 37 | status=json.get('status'), 38 | roles=json.get('roles', None) or ([json['role']] if 'role' in json else list()), 39 | attributes=json.get('attributes', dict()), 40 | create_time=DateTime.parse(json.get('createTime')), 41 | last_login=DateTime.parse(json.get('lastLogin')), 42 | text=json.get('text', None), 43 | update_time=DateTime.parse(json.get('updateTime')), 44 | email_verified=json.get('email_verified', None) 45 | ) 46 | 47 | def tabular(self, timezone=None): 48 | return { 49 | 'id': self.id, 50 | 'name': self.name, 51 | 'email': self.email, 52 | 'status': self.status, 53 | 'roles': ','.join(self.roles), 54 | # 'attributes': self.attributes, # reserved for future use 55 | 'createTime': DateTime.localtime(self.create_time, timezone), 56 | 'lastLogin': DateTime.localtime(self.last_login, timezone), 57 | 'text': self.text, 58 | 'updateTime': DateTime.localtime(self.update_time, timezone), 59 | 'email_verified': 'yes' if self.email_verified else 'no' 60 | } 61 | -------------------------------------------------------------------------------- /tests/unit/test_alerts.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests_mock 4 | 5 | from alertaclient.api import Client 6 | 7 | 8 | class AlertTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.client = Client() 12 | 13 | self.alert = """ 14 | { 15 | "alert": { 16 | "attributes": { 17 | "ip": "127.0.0.1", 18 | "notify": false 19 | }, 20 | "correlate": [], 21 | "createTime": "2017-10-03T09:12:27.283Z", 22 | "customer": null, 23 | "duplicateCount": 4, 24 | "environment": "Production", 25 | "event": "node_down", 26 | "group": "Misc", 27 | "history": [], 28 | "href": "http://localhost:8080/alert/e7020428-5dad-4a41-9bfe-78e9d55cda06", 29 | "id": "e7020428-5dad-4a41-9bfe-78e9d55cda06", 30 | "lastReceiveId": "534ced13-ddb0-435e-8f94-a38691719683", 31 | "lastReceiveTime": "2017-10-03T09:15:06.156Z", 32 | "origin": "alertad/fdaa33ca.local", 33 | "previousSeverity": "indeterminate", 34 | "rawData": null, 35 | "receiveTime": "2017-10-03T09:12:27.289Z", 36 | "repeat": true, 37 | "resource": "web01", 38 | "service": [ 39 | "Web", 40 | "App" 41 | ], 42 | "severity": "critical", 43 | "status": "open", 44 | "tags": [ 45 | "london", 46 | "linux" 47 | ], 48 | "text": "", 49 | "timeout": 86400, 50 | "trendIndication": "moreSevere", 51 | "type": "exceptionAlert", 52 | "value": "4" 53 | }, 54 | "id": "e7020428-5dad-4a41-9bfe-78e9d55cda06", 55 | "status": "ok" 56 | } 57 | """ 58 | 59 | @requests_mock.mock() 60 | def test_alert(self, m): 61 | m.post('http://localhost:8080/alert', text=self.alert) 62 | id, alert, message = self.client.send_alert( 63 | environment='Production', resource='web01', event='node_down', correlated=['node_up', 'node_down'], 64 | service=['Web', 'App'], severity='critical', tags=['london', 'linux'], value=4 65 | ) 66 | self.assertEqual(alert.value, '4') # values cast to string 67 | self.assertEqual(alert.timeout, 86400) # timeout returned as int 68 | self.assertIn('london', alert.tags) 69 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_alerts.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | @click.command('alerts', short_help='List environments, services, groups and tags') 8 | @click.option('--environments', '-E', is_flag=True, help='List alert environments.') 9 | @click.option('--services', '-S', is_flag=True, help='List alert services.') 10 | @click.option('--groups', '-g', is_flag=True, help='List alert groups.') 11 | @click.option('--tags', '-T', is_flag=True, help='List alert tags.') 12 | @click.pass_obj 13 | def cli(obj, environments, services, groups, tags): 14 | """List alert environments, services, groups and tags.""" 15 | 16 | client = obj['client'] 17 | 18 | if environments: 19 | if obj['output'] == 'json': 20 | r = client.http.get('/environments') 21 | click.echo(json.dumps(r['environments'], sort_keys=True, indent=4, ensure_ascii=False)) 22 | else: 23 | headers = {'environment': 'ENVIRONMENT', 'count': 'COUNT', 'severityCounts': 'SEVERITY COUNTS', 'statusCounts': 'STATUS COUNTS'} 24 | click.echo(tabulate(client.get_environments(), headers=headers, tablefmt=obj['output'])) 25 | elif services: 26 | if obj['output'] == 'json': 27 | r = client.http.get('/services') 28 | click.echo(json.dumps(r['services'], sort_keys=True, indent=4, ensure_ascii=False)) 29 | else: 30 | headers = {'environment': 'ENVIRONMENT', 'service': 'SERVICE', 'count': 'COUNT', 'severityCounts': 'SEVERITY COUNTS', 'statusCounts': 'STATUS COUNTS'} 31 | click.echo(tabulate(client.get_services(), headers=headers, tablefmt=obj['output'])) 32 | elif groups: 33 | if obj['output'] == 'json': 34 | r = client.http.get('/alerts/groups') 35 | click.echo(json.dumps(r['groups'], sort_keys=True, indent=4, ensure_ascii=False)) 36 | else: 37 | headers = {'environment': 'ENVIRONMENT', 'group': 'GROUP', 'count': 'COUNT', 'severityCounts': 'SEVERITY COUNTS', 'statusCounts': 'STATUS COUNTS'} 38 | click.echo(tabulate(client.get_groups(), headers=headers, tablefmt=obj['output'])) 39 | elif tags: 40 | if obj['output'] == 'json': 41 | r = client.http.get('/alerts/tags') 42 | click.echo(json.dumps(r['tags'], sort_keys=True, indent=4, ensure_ascii=False)) 43 | else: 44 | headers = {'environment': 'ENVIRONMENT', 'tag': 'TAG', 'count': 'COUNT', 'severityCounts': 'SEVERITY COUNTS', 'statusCounts': 'STATUS COUNTS'} 45 | click.echo(tabulate(client.get_tags(), headers=headers, tablefmt=obj['output'])) 46 | else: 47 | raise click.UsageError('Must choose an alert attribute to list.') 48 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import unittest 4 | 5 | from alertaclient.api import Client 6 | from alertaclient.config import Config 7 | 8 | 9 | @contextlib.contextmanager 10 | def mod_env(*remove, **update): 11 | """ 12 | See https://stackoverflow.com/questions/2059482#34333710 13 | 14 | Temporarily updates the ``os.environ`` dictionary in-place. 15 | 16 | The ``os.environ`` dictionary is updated in-place so that the modification 17 | is sure to work in all situations. 18 | 19 | :param remove: Environment variables to remove. 20 | :param update: Dictionary of environment variables and values to add/update. 21 | """ 22 | env = os.environ 23 | update = update or {} 24 | remove = remove or [] 25 | 26 | # List of environment variables being updated or removed. 27 | stomped = (set(update.keys()) | set(remove)) & set(env.keys()) 28 | # Environment variables and values to restore on exit. 29 | update_after = {k: env[k] for k in stomped} 30 | # Environment variables and values to remove on exit. 31 | remove_after = frozenset(k for k in update if k not in env) 32 | 33 | try: 34 | env.update(update) 35 | [env.pop(k, None) for k in remove] 36 | yield 37 | finally: 38 | env.update(update_after) 39 | [env.pop(k) for k in remove_after] 40 | 41 | 42 | class CommandsTestCase(unittest.TestCase): 43 | 44 | def setUp(self): 45 | pass 46 | 47 | def test_env_vars(self): 48 | 49 | config = Config(config_file=None) 50 | self.assertEqual(config.options['config_file'], '~/.alerta.conf') 51 | 52 | with mod_env( 53 | ALERTA_CONF_FILE='~/.alerta.test.conf', 54 | ALERTA_DEFAULT_PROFILE='test-profile', 55 | ALERTA_ENDPOINT='http://foo/bar/baz', 56 | ALERTA_API_KEY='test-key', 57 | REQUESTS_CA_BUNDLE='', 58 | CLICOLOR='', 59 | DEBUG='1' 60 | ): 61 | 62 | # conf file 63 | config = Config(config_file=None) 64 | self.assertEqual(config.options['config_file'], '~/.alerta.test.conf', os.environ) 65 | config = Config(config_file='/dev/null') 66 | self.assertEqual(config.options['config_file'], '/dev/null') 67 | 68 | # profile 69 | config = Config(config_file=None) 70 | config.get_config_for_profle() 71 | self.assertEqual(config.options['profile'], 'test-profile', os.environ) 72 | 73 | # endpoint 74 | self.client = Client() 75 | self.assertEqual(self.client.endpoint, 'http://foo/bar/baz') 76 | 77 | # api key 78 | self.assertEqual(config.options['key'], 'test-key') 79 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_heartbeat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | from alertaclient.utils import origin 6 | 7 | 8 | @click.command('heartbeat', short_help='Send a heartbeat') 9 | @click.option('--origin', '-O', metavar='ORIGIN', default=origin, help='Origin of heartbeat.') 10 | @click.option('--environment', '-E', metavar='ENVIRONMENT', help='Environment eg. Production, Development') 11 | @click.option('--severity', '-s', metavar='SEVERITY', help='Severity override eg. critical, major, minor, warning') 12 | @click.option('--service', '-S', metavar='SERVICE', multiple=True, help='List of affected services eg. app name, Web, Network, Storage, Database, Security') 13 | @click.option('--group', '-g', metavar='GROUP', help='Group event by type eg. OS, Performance') 14 | @click.option('--tag', '-T', 'tags', multiple=True, metavar='TAG', help='List of tags eg. London, os:linux, AWS/EC2') 15 | @click.option('--timeout', metavar='SECONDS', type=int, help='Seconds before heartbeat is stale') 16 | @click.option('--customer', metavar='STRING', help='Customer') 17 | @click.option('--delete', '-D', metavar='ID', help='Delete hearbeat using ID') 18 | @click.pass_obj 19 | def cli(obj, origin, environment, severity, service, group, tags, timeout, customer, delete): 20 | """ 21 | Send or delete a heartbeat. 22 | 23 | Note: The "environment", "severity", "service" and "group" values are only 24 | used when heartbeat alerts are generated from slow or stale heartbeats. 25 | """ 26 | client = obj['client'] 27 | if delete: 28 | client.delete_heartbeat(delete) 29 | else: 30 | if any(t.startswith('environment') or t.startswith('group') for t in tags): 31 | click.secho('WARNING: Using tags for "environment" or "group" is deprecated. See help.', err=True) 32 | 33 | if severity in ['normal', 'ok', 'cleared']: 34 | raise click.UsageError('Must be a non-normal severity. "{}" is one of {}'.format( 35 | severity, ', '.join(['normal', 'ok', 'cleared'])) 36 | ) 37 | 38 | if severity and severity not in obj['alarm_model']['severity'].keys(): 39 | raise click.UsageError('Must be a valid severity. "{}" is not one of {}'.format( 40 | severity, ', '.join(obj['alarm_model']['severity'].keys())) 41 | ) 42 | attributes = dict() 43 | if environment: 44 | attributes['environment'] = environment 45 | if severity: 46 | attributes['severity'] = severity 47 | if service: 48 | attributes['service'] = service 49 | if group: 50 | attributes['group'] = group 51 | 52 | try: 53 | heartbeat = client.heartbeat(origin=origin, tags=tags, attributes=attributes, timeout=timeout, customer=customer) 54 | except Exception as e: 55 | click.echo(f'ERROR: {e}', err=True) 56 | sys.exit(1) 57 | click.echo(heartbeat.id) 58 | -------------------------------------------------------------------------------- /alertaclient/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import os 4 | 5 | import requests 6 | 7 | from alertaclient.exceptions import ClientException 8 | 9 | default_config = { 10 | 'config_file': '~/.alerta.conf', 11 | 'profile': None, 12 | 'endpoint': 'http://localhost:8080', 13 | 'key': '', 14 | 'secret': None, 15 | 'client_id': None, 16 | 'username': None, 17 | 'password': None, 18 | 'timezone': 'Europe/London', 19 | 'timeout': 5.0, 20 | 'sslverify': True, 21 | 'sslcert': None, 22 | 'sslkey': None, 23 | 'output': 'simple', 24 | 'color': True, 25 | 'debug': False 26 | } 27 | 28 | 29 | class Config: 30 | 31 | def __init__(self, config_file, config_override=None): 32 | self.options = default_config 33 | self.parser = configparser.RawConfigParser(defaults=self.options) 34 | 35 | self.options['config_file'] = config_file or os.environ.get('ALERTA_CONF_FILE') or self.options['config_file'] 36 | self.parser.read(os.path.expanduser(self.options['config_file'])) 37 | 38 | self.options.update(config_override or {}) 39 | 40 | def get_config_for_profle(self, profile=None): 41 | want_profile = profile or os.environ.get('ALERTA_DEFAULT_PROFILE') or self.parser.defaults().get('profile') 42 | 43 | if want_profile and self.parser.has_section('profile %s' % want_profile): 44 | for opt in self.options: 45 | try: 46 | self.options[opt] = self.parser.getboolean('profile %s' % want_profile, opt) 47 | except (ValueError, AttributeError): 48 | self.options[opt] = self.parser.get('profile %s' % want_profile, opt) 49 | else: 50 | for opt in self.options: 51 | try: 52 | self.options[opt] = self.parser.getboolean('DEFAULT', opt) 53 | except (ValueError, AttributeError): 54 | self.options[opt] = self.parser.get('DEFAULT', opt) 55 | 56 | self.options['profile'] = want_profile 57 | self.options['endpoint'] = os.environ.get('ALERTA_ENDPOINT', self.options['endpoint']) 58 | self.options['key'] = os.environ.get('ALERTA_API_KEY', self.options['key']) 59 | 60 | def get_remote_config(self, endpoint=None): 61 | config_url = '{}/config'.format(endpoint or self.options['endpoint']) 62 | try: 63 | r = requests.get(config_url, verify=self.options['sslverify'], cert=(self.options['sslcert'], self.options['sslkey'])) 64 | r.raise_for_status() 65 | remote_config = r.json() 66 | except requests.RequestException as e: 67 | raise ClientException(f'Failed to get config from {config_url}. Reason: {e}') 68 | except json.decoder.JSONDecodeError: 69 | raise ClientException(f'Failed to get config from {config_url}: Reason: not a JSON object') 70 | 71 | self.options = {**remote_config, **self.options} 72 | -------------------------------------------------------------------------------- /alertaclient/models/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Scope(str): 5 | 6 | read = 'read' 7 | write = 'write' 8 | admin = 'admin' 9 | read_alerts = 'read:alerts' 10 | write_alerts = 'write:alerts' 11 | delete_alerts = 'delete:alerts' 12 | admin_alerts = 'admin:alerts' 13 | read_blackouts = 'read:blackouts' 14 | write_blackouts = 'write:blackouts' 15 | admin_blackouts = 'admin:blackouts' 16 | read_heartbeats = 'read:heartbeats' 17 | write_heartbeats = 'write:heartbeats' 18 | admin_heartbeats = 'admin:heartbeats' 19 | write_users = 'write:users' 20 | admin_users = 'admin:users' 21 | read_groups = 'read:groups' 22 | admin_groups = 'admin:groups' 23 | read_perms = 'read:perms' 24 | admin_perms = 'admin:perms' 25 | read_customers = 'read:customers' 26 | admin_customers = 'admin:customers' 27 | read_keys = 'read:keys' 28 | write_keys = 'write:keys' 29 | admin_keys = 'admin:keys' 30 | write_webhooks = 'write:webhooks' 31 | read_oembed = 'read:oembed' 32 | read_management = 'read:management' 33 | admin_management = 'admin:management' 34 | read_userinfo = 'read:userinfo' 35 | 36 | @property 37 | def action(self): 38 | return self.split(':')[0] 39 | 40 | @property 41 | def resource(self): 42 | try: 43 | return self.split(':')[1] 44 | except IndexError: 45 | return None 46 | 47 | @staticmethod 48 | def from_str(action: str, resource: str = None): 49 | """Return a scope based on the supplied action and resource. 50 | 51 | :param action: the scope action eg. read, write or admin 52 | :param resource: the specific resource of the scope, if any eg. alerts, 53 | blackouts, heartbeats, users, perms, customers, keys, webhooks, 54 | oembed, management or userinfo or None 55 | :return: Scope 56 | """ 57 | if resource: 58 | return Scope(f'{action}:{resource}') 59 | else: 60 | return Scope(action) 61 | 62 | def tabular(self): 63 | return { 64 | 'scope': self 65 | } 66 | 67 | 68 | ADMIN_SCOPES = [Scope.admin, Scope.read, Scope.write] 69 | 70 | 71 | class ChangeType(str, Enum): 72 | 73 | open = 'open' 74 | assign = 'assign' 75 | ack = 'ack' 76 | unack = 'unack' 77 | shelve = 'shelve' 78 | unshelve = 'unshelve' 79 | close = 'close' 80 | 81 | new = 'new' 82 | action = 'action' 83 | status = 'status' 84 | value = 'value' 85 | severity = 'severity' 86 | note = 'note' 87 | timeout = 'timeout' 88 | expired = 'expired' 89 | 90 | 91 | class NoteType(str, Enum): 92 | 93 | alert = 'alert' 94 | blackout = 'blackout' 95 | customer = 'customer' 96 | group = 'group' 97 | heartbeat = 'heartbeat' 98 | key = 'api-key' 99 | perm = 'permission' 100 | user = 'user' 101 | -------------------------------------------------------------------------------- /alertaclient/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import click 5 | 6 | from alertaclient.api import Client 7 | from alertaclient.auth.utils import get_token 8 | from alertaclient.config import Config 9 | 10 | CONTEXT_SETTINGS = dict( 11 | auto_envvar_prefix='ALERTA', 12 | default_map={'query': {'compact': True}}, 13 | help_option_names=['-h', '--help'], 14 | ) 15 | cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), 'commands')) 16 | 17 | 18 | class AlertaCLI(click.MultiCommand): 19 | 20 | def list_commands(self, ctx): 21 | rv = [] 22 | for filename in os.listdir(cmd_folder): 23 | if filename.endswith('.py') and \ 24 | filename.startswith('cmd_'): 25 | rv.append(filename[4:-3]) 26 | rv.sort() 27 | return rv 28 | 29 | def get_command(self, ctx, name): 30 | try: 31 | if sys.version_info[0] == 2: 32 | name = name.encode('ascii', 'replace') 33 | mod = __import__('alertaclient.commands.cmd_' + name, None, None, ['cli']) 34 | except ImportError: 35 | return 36 | return mod.cli 37 | 38 | 39 | @click.command(cls=AlertaCLI, context_settings=CONTEXT_SETTINGS) 40 | @click.option('--config-file', metavar='', help='Configuration file.') 41 | @click.option('--profile', metavar='', help='Configuration profile.') 42 | @click.option('--endpoint-url', metavar='', help='API endpoint URL.') 43 | @click.option('--output', 'output', metavar='', help='Output format. eg. plain, simple, grid, psql, presto, rst, html, json, json_lines') 44 | @click.option('--json', 'output', flag_value='json', help='Output in JSON format. Shortcut for "--output json"') 45 | @click.option('--color/--no-color', help='Color-coded output based on severity.') 46 | @click.option('--debug', is_flag=True, help='Debug mode.') 47 | @click.pass_context 48 | def cli(ctx, config_file, profile, endpoint_url, output, color, debug): 49 | """ 50 | Alerta client unified command-line tool. 51 | """ 52 | config = Config(config_file) 53 | config.get_config_for_profle(profile) 54 | config.get_remote_config(endpoint_url) 55 | 56 | ctx.obj = config.options 57 | 58 | # override current options with command-line options or environment variables 59 | ctx.obj['output'] = output or config.options['output'] 60 | ctx.obj['color'] = color or os.environ.get('CLICOLOR', None) or config.options['color'] 61 | endpoint = endpoint_url or config.options['endpoint'] 62 | 63 | ctx.obj['client'] = Client( 64 | endpoint=endpoint, 65 | key=config.options['key'], 66 | secret=config.options['secret'], 67 | token=get_token(endpoint), 68 | username=config.options.get('username', None), 69 | password=config.options.get('password', None), 70 | timeout=float(config.options['timeout']), 71 | ssl_verify=config.options['sslverify'], 72 | ssl_cert=config.options.get('sslcert', None), 73 | ssl_key=config.options.get('sslkey', None), 74 | debug=debug or os.environ.get('DEBUG', None) or config.options['debug'] 75 | ) 76 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!make 2 | 3 | VENV=venv 4 | PYTHON=$(VENV)/bin/python3 5 | PIP=$(VENV)/bin/pip --disable-pip-version-check 6 | FLAKE8=$(VENV)/bin/flake8 7 | MYPY=$(VENV)/bin/mypy 8 | TOX=$(VENV)/bin/tox 9 | PYTEST=$(VENV)/bin/pytest 10 | DOCKER_COMPOSE=docker-compose 11 | PRE_COMMIT=$(VENV)/bin/pre-commit 12 | BUILD=$(VENV)/bin/build 13 | WHEEL=$(VENV)/bin/wheel 14 | TWINE=$(VENV)/bin/twine 15 | GIT=git 16 | 17 | .DEFAULT_GOAL:=help 18 | 19 | -include .env .env.local .env.*.local 20 | 21 | ifndef PROJECT 22 | $(error PROJECT is not set) 23 | endif 24 | 25 | PYPI_REPOSITORY ?= pypi 26 | VERSION=$(shell cut -d "'" -f 2 $(PROJECT)/version.py) 27 | 28 | all: help 29 | 30 | $(VENV): 31 | python3 -m venv $(VENV) 32 | 33 | $(FLAKE8): $(VENV) 34 | $(PIP) install flake8 35 | 36 | $(MYPY): $(VENV) 37 | $(PIP) install mypy 38 | 39 | $(TOX): $(VENV) 40 | $(PIP) install tox 41 | 42 | $(PYTEST): $(VENV) 43 | $(PIP) install pytest pytest-cov 44 | 45 | $(PRE_COMMIT): $(VENV) 46 | $(PIP) install pre-commit 47 | $(PRE_COMMIT) install 48 | 49 | $(BUILD): $(VENV) 50 | $(PIP) install --upgrade build 51 | 52 | $(WHEEL): $(VENV) 53 | $(PIP) install --upgrade wheel 54 | 55 | $(TWINE): $(VENV) 56 | $(PIP) install --upgrade wheel twine 57 | 58 | ifdef TOXENV 59 | toxparams?=-e $(TOXENV) 60 | endif 61 | 62 | ## install - Install dependencies. 63 | install: $(VENV) 64 | $(PIP) install -r requirements.txt 65 | 66 | ## hooks - Run pre-commit hooks. 67 | hooks: $(PRE_COMMIT) 68 | $(PRE_COMMIT) run --all-files --show-diff-on-failure 69 | 70 | ## lint - Lint and type checking. 71 | lint: $(FLAKE8) $(MYPY) 72 | $(FLAKE8) $(PROJECT)/ 73 | $(MYPY) $(PROJECT)/ 74 | 75 | ## test - Run all tests. 76 | test: test.unit test.integration 77 | 78 | ## test.unit - Run unit tests. 79 | test.unit: $(TOX) $(PYTEST) 80 | $(TOX) $(toxparams) 81 | 82 | ## test.integration - Run integration tests. 83 | test.integration: 84 | $(DOCKER_COMPOSE) -f docker-compose.ci.yaml rm --stop --force 85 | $(DOCKER_COMPOSE) -f docker-compose.ci.yaml pull 86 | $(DOCKER_COMPOSE) -f docker-compose.ci.yaml build sut 87 | $(DOCKER_COMPOSE) -f docker-compose.ci.yaml up --exit-code-from sut 88 | $(DOCKER_COMPOSE) -f docker-compose.ci.yaml rm --stop --force 89 | 90 | ## run - Run application. 91 | run: 92 | alerta 93 | 94 | ## tag - Git tag with current version. 95 | tag: 96 | $(GIT) tag -a v$(VERSION) -m "version $(VERSION)" 97 | $(GIT) push --tags 98 | 99 | ## build - Build package. 100 | build: $(BUILD) 101 | $(PYTHON) -m build 102 | 103 | ## upload - Upload package to PyPI. 104 | upload: $(TWINE) 105 | $(TWINE) check dist/* 106 | $(TWINE) upload --repository $(PYPI_REPOSITORY) --verbose dist/* 107 | 108 | ## clean - Clean source. 109 | clean: 110 | rm -rf $(VENV) 111 | rm -rf .tox 112 | rm -rf dist 113 | rm -rf build 114 | find . -name "*.pyc" -exec rm {} \; 115 | 116 | ## help - Show this help. 117 | help: Makefile 118 | @echo '' 119 | @echo 'Usage:' 120 | @echo ' make [TARGET]' 121 | @echo '' 122 | @echo 'Targets:' 123 | @sed -n 's/^##//p' $< 124 | @echo '' 125 | 126 | @echo 'Add project-specific env variables to .env file:' 127 | @echo 'PROJECT=$(PROJECT)' 128 | 129 | .PHONY: help lint test build sdist wheel clean all 130 | -------------------------------------------------------------------------------- /alertaclient/models/heartbeat.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from alertaclient.utils import DateTime 4 | 5 | DEFAULT_MAX_LATENCY = 2000 # ms 6 | 7 | 8 | class Heartbeat: 9 | 10 | def __init__(self, origin=None, tags=None, create_time=None, timeout=None, customer=None, **kwargs): 11 | if any(['.' in key for key in kwargs.get('attributes', dict()).keys()])\ 12 | or any(['$' in key for key in kwargs.get('attributes', dict()).keys()]): 13 | raise ValueError('Attribute keys must not contain "." or "$"') 14 | 15 | self.id = kwargs.get('id', None) 16 | self.origin = origin 17 | self.status = kwargs.get('status', None) or 'unknown' 18 | self.tags = tags or list() 19 | self.attributes = kwargs.get('attributes', None) or dict() 20 | self.event_type = kwargs.get('event_type', kwargs.get('type', None)) or 'Heartbeat' 21 | self.create_time = create_time 22 | self.timeout = timeout 23 | self.max_latency = kwargs.get('max_latency', None) or DEFAULT_MAX_LATENCY 24 | self.receive_time = kwargs.get('receive_time', None) 25 | self.customer = customer 26 | 27 | @property 28 | def latency(self): 29 | return int((self.receive_time - self.create_time).total_seconds() * 1000) 30 | 31 | @property 32 | def since(self): 33 | since = datetime.utcnow() - self.receive_time 34 | return since - timedelta(microseconds=since.microseconds) 35 | 36 | def __repr__(self): 37 | return 'Heartbeat(id={!r}, origin={!r}, create_time={!r}, timeout={!r}, customer={!r})'.format( 38 | self.id, self.origin, self.create_time, self.timeout, self.customer) 39 | 40 | @classmethod 41 | def parse(cls, json): 42 | if not isinstance(json.get('tags', []), list): 43 | raise ValueError('tags must be a list') 44 | if not isinstance(json.get('attributes', {}), dict): 45 | raise ValueError('attributes must be a JSON object') 46 | if not isinstance(json.get('timeout', 0), int): 47 | raise ValueError('timeout must be an integer') 48 | 49 | return Heartbeat( 50 | id=json.get('id', None), 51 | origin=json.get('origin', None), 52 | status=json.get('status', None), 53 | tags=json.get('tags', list()), 54 | attributes=json.get('attributes', dict()), 55 | event_type=json.get('type', None), 56 | create_time=DateTime.parse(json.get('createTime')), 57 | timeout=json.get('timeout', None), 58 | max_latency=json.get('maxLatency', None) or DEFAULT_MAX_LATENCY, 59 | receive_time=DateTime.parse(json.get('receiveTime')), 60 | customer=json.get('customer', None) 61 | ) 62 | 63 | def tabular(self, timezone=None): 64 | return { 65 | 'id': self.id, 66 | 'origin': self.origin, 67 | 'customer': self.customer, 68 | 'tags': ','.join(self.tags), 69 | 'attributes': self.attributes, 70 | 'createTime': DateTime.localtime(self.create_time, timezone), 71 | 'receiveTime': DateTime.localtime(self.receive_time, timezone), 72 | 'since': self.since, 73 | 'timeout': f'{self.timeout}s', 74 | 'latency': f'{self.latency:.0f}ms', 75 | 'maxLatency': f'{self.max_latency}ms', 76 | 'status': self.status 77 | } 78 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_user.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | 7 | class CommandWithOptionalPassword(click.Command): 8 | 9 | def parse_args(self, ctx, args): 10 | for i, a in enumerate(args): 11 | if args[i] == '--password': 12 | try: 13 | password = args[i + 1] if not args[i + 1].startswith('--') else None 14 | except IndexError: 15 | password = None 16 | if not password: 17 | password = click.prompt('Password', hide_input=True, confirmation_prompt=True) 18 | args.insert(i + 1, password) 19 | return super().parse_args(ctx, args) 20 | 21 | 22 | @click.command('user', cls=CommandWithOptionalPassword, short_help='Update user') 23 | @click.option('--id', '-i', metavar='ID', help='User ID') 24 | @click.option('--name', help='Name of user') 25 | @click.option('--email', help='Email address (login username)') 26 | @click.option('--password', help='Password (will prompt if not supplied)') 27 | @click.option('--status', help='Status eg. active, inactive') 28 | @click.option('--role', 'roles', multiple=True, help='List of roles') 29 | @click.option('--text', help='Description of user') 30 | @click.option('--email-verified/--email-not-verified', default=None, help='Email address verified flag') 31 | @click.option('--groups', is_flag=True, help='Get list of user groups') 32 | @click.option('--delete', '-D', metavar='ID', help='Delete user using ID') 33 | @click.pass_obj 34 | def cli(obj, id, name, email, password, status, roles, text, email_verified, groups, delete): 35 | """Create user, show or update user details, including password reset, list user groups and delete user.""" 36 | client = obj['client'] 37 | if groups: 38 | user_groups = client.get_user_groups(id) 39 | headers = {'id': 'ID', 'name': 'USER', 'text': 'TEXT', 'count': 'COUNT'} 40 | click.echo(tabulate([ug.tabular() for ug in user_groups], headers=headers, tablefmt=obj['output'])) 41 | elif delete: 42 | client.delete_user(delete) 43 | elif id: 44 | if not any([name, email, password, status, roles, text, (email_verified is not None)]): 45 | user = client.get_user(id) 46 | timezone = obj['timezone'] 47 | headers = {'id': 'ID', 'name': 'USER', 'email': 'EMAIL', 'roles': 'ROLES', 'status': 'STATUS', 48 | 'text': 'TEXT', 49 | 'createTime': 'CREATED', 'updateTime': 'LAST UPDATED', 'lastLogin': 'LAST LOGIN', 50 | 'email_verified': 'VERIFIED'} 51 | click.echo(tabulate([user.tabular(timezone)], headers=headers, tablefmt=obj['output'])) 52 | else: 53 | try: 54 | user = client.update_user( 55 | id, name=name, email=email, password=password, status=status, 56 | roles=roles, attributes=None, text=text, email_verified=email_verified 57 | ) 58 | except Exception as e: 59 | click.echo(f'ERROR: {e}', err=True) 60 | sys.exit(1) 61 | click.echo(user.id) 62 | else: 63 | if not email: 64 | raise click.UsageError('Need "--email" to create user.') 65 | if not password: 66 | password = click.prompt('Password', hide_input=True) 67 | try: 68 | user = client.create_user( 69 | name=name, email=email, password=password, status=status, 70 | roles=roles, attributes=None, text=text, email_verified=email_verified 71 | ) 72 | except Exception as e: 73 | click.echo(f'ERROR: {e}', err=True) 74 | sys.exit(1) 75 | click.echo(user.id) 76 | -------------------------------------------------------------------------------- /alertaclient/models/history.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from alertaclient.utils import DateTime 4 | 5 | 6 | class History: 7 | 8 | def __init__(self, id, event, **kwargs): 9 | self.id = id 10 | self.event = event 11 | self.severity = kwargs.get('severity', None) 12 | self.status = kwargs.get('status', None) 13 | self.value = kwargs.get('value', None) 14 | self.text = kwargs.get('text', None) 15 | self.change_type = kwargs.get('change_type', kwargs.get('type', None)) or '' 16 | self.update_time = kwargs.get('update_time', None) or datetime.utcnow() 17 | self.user = kwargs.get('user', None) 18 | 19 | def __repr__(self): 20 | return 'History(id={!r}, event={!r}, severity={!r}, status={!r}, type={!r})'.format( 21 | self.id, self.event, self.severity, self.status, self.change_type) 22 | 23 | 24 | class RichHistory: 25 | 26 | def __init__(self, resource, event, **kwargs): 27 | 28 | self.id = kwargs.get('id', None) 29 | self.resource = resource 30 | self.event = event 31 | self.environment = kwargs.get('environment', None) 32 | self.severity = kwargs.get('severity', None) 33 | self.status = kwargs.get('status', None) 34 | self.service = kwargs.get('service', None) or list() 35 | self.group = kwargs.get('group', None) 36 | self.value = kwargs.get('value', None) 37 | self.text = kwargs.get('text', None) 38 | self.tags = kwargs.get('tags', None) or list() 39 | self.attributes = kwargs.get('attributes', None) or dict() 40 | self.origin = kwargs.get('origin', None) 41 | self.update_time = kwargs.get('update_time', None) 42 | self.user = kwargs.get('user', None) 43 | self.change_type = kwargs.get('change_type', kwargs.get('type', None)) 44 | self.customer = kwargs.get('customer', None) 45 | 46 | def __repr__(self): 47 | return 'RichHistory(id={!r}, environment={!r}, resource={!r}, event={!r}, severity={!r}, status={!r}, type={!r}, customer={!r})'.format( 48 | self.id, self.environment, self.resource, self.event, self.severity, self.status, self.change_type, self.customer) 49 | 50 | @classmethod 51 | def parse(cls, json): 52 | if not isinstance(json.get('service', []), list): 53 | raise ValueError('service must be a list') 54 | if not isinstance(json.get('tags', []), list): 55 | raise ValueError('tags must be a list') 56 | 57 | return RichHistory( 58 | id=json.get('id', None), 59 | resource=json.get('resource', None), 60 | event=json.get('event', None), 61 | environment=json.get('environment', None), 62 | severity=json.get('severity', None), 63 | status=json.get('status', None), 64 | service=json.get('service', list()), 65 | group=json.get('group', None), 66 | value=json.get('value', None), 67 | text=json.get('text', None), 68 | tags=json.get('tags', list()), 69 | attributes=json.get('attributes', dict()), 70 | origin=json.get('origin', None), 71 | change_type=json.get('type', None), 72 | update_time=DateTime.parse(json.get('updateTime')), 73 | customer=json.get('customer', None) 74 | ) 75 | 76 | def tabular(self, timezone=None): 77 | data = { 78 | 'id': self.id, 79 | 'resource': self.resource, 80 | 'event': self.event, 81 | 'environment': self.environment, 82 | 'service': ','.join(self.service), 83 | 'group': self.group, 84 | 'text': self.text, 85 | # 'tags': ','.join(self.tags), # not displayed 86 | # 'attributes': self.attributes, 87 | # 'origin': self.origin, 88 | 'type': self.change_type, 89 | 'updateTime': DateTime.localtime(self.update_time, timezone), 90 | 'customer': self.customer 91 | } 92 | 93 | if self.severity: 94 | data['severity'] = self.severity 95 | data['value'] = self.value 96 | 97 | if self.status: 98 | data['status'] = self.status 99 | 100 | return data 101 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_keys.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | 4 | import click 5 | from tabulate import tabulate 6 | 7 | from alertaclient.utils import origin 8 | 9 | 10 | @click.command('keys', short_help='List API keys') 11 | @click.option('--alert', is_flag=True, help='Alert on expiring and expired keys') 12 | @click.option('--maxage', metavar='DAYS', default=7, type=int, help='Max remaining days before alerting') 13 | @click.option('--timeout', metavar='SECONDS', default=86400, type=int, help='Seconds before expired key alerts will be expired') 14 | @click.option('--severity', '-s', metavar='SEVERITY', default='warning', help='Severity for expiring and expired alerts') 15 | @click.pass_obj 16 | def cli(obj, alert, maxage, severity, timeout): 17 | """List API keys.""" 18 | client = obj['client'] 19 | 20 | if obj['output'] == 'json': 21 | r = client.http.get('/keys') 22 | click.echo(json.dumps(r['keys'], sort_keys=True, indent=4, ensure_ascii=False)) 23 | else: 24 | timezone = obj['timezone'] 25 | headers = { 26 | 'id': 'ID', 'key': 'API KEY', 'user': 'USER', 'scopes': 'SCOPES', 'text': 'TEXT', 27 | 'expireTime': 'EXPIRES', 'count': 'COUNT', 'lastUsedTime': 'LAST USED', 'customer': 'CUSTOMER' 28 | } 29 | click.echo(tabulate([k.tabular(timezone) for k in client.get_keys()], headers=headers, tablefmt=obj['output'])) 30 | 31 | if alert: 32 | keys = r['keys'] 33 | service = ['Alerta'] 34 | group = 'System' 35 | environment = 'Production' 36 | with click.progressbar(keys, label=f'Analysing {len(keys)} keys') as bar: 37 | for b in bar: 38 | if b['status'] == 'expired': 39 | client.send_alert( 40 | resource=b['id'], 41 | event='ApiKeyExpired', 42 | environment=environment, 43 | severity=severity, 44 | correlate=['ApiKeyExpired', 'ApiKeyExpiring', 'ApiKeyOK'], 45 | service=service, 46 | group=group, 47 | value='Expired', 48 | text=f"Key expired on {b['expireTime']}", 49 | origin=origin(), 50 | type='apiKeyAlert', 51 | timeout=timeout, 52 | customer=b['customer'] 53 | ) 54 | elif b['status'] == 'active': 55 | expiration = datetime.fromisoformat(b['expireTime'].split('.')[0]) 56 | remaining_validity = expiration - datetime.now() 57 | if remaining_validity < timedelta(days=maxage): 58 | client.send_alert( 59 | resource=b['id'], 60 | event='ApiKeyExpiring', 61 | environment=environment, 62 | severity=severity, 63 | correlate=['ApiKeyExpired', 'ApiKeyExpiring', 'ApiKeyOK'], 64 | service=service, 65 | group=group, 66 | value=str(remaining_validity), 67 | text=f"Key is active and expires on {b['expireTime']}", 68 | origin=origin(), 69 | type='apiKeyAlert', 70 | timeout=timeout, 71 | customer=b['customer'] 72 | ) 73 | else: 74 | client.send_alert( 75 | resource=b['id'], 76 | event='ApiKeyOK', 77 | environment=environment, 78 | severity='ok', 79 | correlate=['ApiKeyExpired', 'ApiKeyExpiring', 'ApiKeyOK'], 80 | service=service, 81 | group=group, 82 | value=str(remaining_validity), 83 | text=f"Key is active and expires on {b['expireTime']}", 84 | origin=origin(), 85 | type='apiKeyAlert', 86 | timeout=timeout, 87 | customer=b['customer'] 88 | ) 89 | -------------------------------------------------------------------------------- /tests/unit/test_remoteconfig.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests 4 | import requests_mock 5 | from requests_mock import Adapter 6 | 7 | from alertaclient.cli import Config 8 | from alertaclient.exceptions import ClientException 9 | 10 | 11 | class RemoteConfigTestCase(unittest.TestCase): 12 | 13 | def setUp(self): 14 | self.adapter = Adapter() 15 | self.config = Config('') 16 | self.remote_json_config = """ 17 | { 18 | "actions": [], 19 | "alarm_model": { 20 | "name": "Alerta 8.0.1" 21 | }, 22 | "audio": { 23 | "new": null 24 | }, 25 | "auth_required": false, 26 | "azure_tenant": null, 27 | "client_id": null, 28 | "colors": { 29 | "highlight": "skyblue ", 30 | "severity": { 31 | "cleared": "#00CC00", 32 | "critical": "red", 33 | "debug": "#9D006D", 34 | "indeterminate": "lightblue", 35 | "informational": "#00CC00", 36 | "major": "orange", 37 | "minor": "yellow", 38 | "normal": "#00CC00", 39 | "ok": "#00CC00", 40 | "security": "blue", 41 | "trace": "#7554BF", 42 | "unknown": "silver", 43 | "warning": "dodgerblue" 44 | }, 45 | "text": "black" 46 | }, 47 | "columns": ["severity", "status", "lastReceiveTime", 48 | "duplicateCount", 49 | "customer", "environment", "service", "resource", 50 | "event", "value", "text"], 51 | "customer_views": false, 52 | "dates": { 53 | "longDate": "d/M/yyyy h:mm:ss.sss a", 54 | "mediumDate": "EEE d MMM HH:mm", 55 | "shortTime": "HH:mm" 56 | }, 57 | "email_verification": false, 58 | "endpoint": "http://localhost:8080/api", 59 | "github_url": "https://github.com", 60 | "gitlab_url": "https://gitlab.com", 61 | "keycloak_realm": null, 62 | "keycloak_url": null, 63 | "pingfederate_url": null, 64 | "provider": "basic", 65 | "refresh_interval": 5000, 66 | "severity": { 67 | "cleared": 5, 68 | "critical": 1, 69 | "debug": 7, 70 | "indeterminate": 5, 71 | "informational": 6, 72 | "major": 2, 73 | "minor": 3, 74 | "normal": 5, 75 | "ok": 5, 76 | "security": 0, 77 | "trace": 8, 78 | "unknown": 9, 79 | "warning": 4 80 | }, 81 | "signup_enabled": true, 82 | "site_logo_url": "", 83 | "sort_by": "lastReceiveTime", 84 | "tracking_id": null 85 | } 86 | """ 87 | 88 | @requests_mock.mock() 89 | def test_config_success(self, m): 90 | """Tests successful remote config fetch""" 91 | m.get('/api/config', text=self.remote_json_config, status_code=200) 92 | self.config.get_remote_config('http://localhost:8080/api') 93 | self.assertEqual(self.config.options['alarm_model']['name'], 'Alerta 8.0.1') 94 | 95 | @requests_mock.mock() 96 | def test_config_timeout(self, m): 97 | m.get('/api/config', exc=requests.exceptions.ConnectTimeout) 98 | with self.assertRaises(ClientException): 99 | self.config.get_remote_config('http://localhost:8080/api') 100 | 101 | @requests_mock.mock() 102 | def test_config_not_found(self, m): 103 | 104 | m.get('/config', status_code=404) 105 | with self.assertRaises(ClientException): 106 | self.config.get_remote_config('http://localhost:8080') 107 | 108 | @requests_mock.mock() 109 | def test_config_not_json(self, m): 110 | """Tests that URL is accessible (HTTP 200) 111 | but there is no Alerta API config in JSON""" 112 | 113 | m.get('/sometext/config', text='Some random text', status_code=200) 114 | with self.assertRaises(ClientException): 115 | self.config.get_remote_config('http://localhost:8080/sometext') 116 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_send.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | 5 | import click 6 | 7 | 8 | @click.command('send', short_help='Send an alert') 9 | @click.option('--resource', '-r', metavar='RESOURCE', required=False, help='Resource under alarm') 10 | @click.option('--event', '-e', metavar='EVENT', required=False, help='Event name') 11 | @click.option('--environment', '-E', metavar='ENVIRONMENT', help='Environment eg. Production, Development') 12 | @click.option('--severity', '-s', metavar='SEVERITY', help='Severity eg. critical, major, minor, warning') 13 | @click.option('--correlate', '-C', metavar='EVENT', multiple=True, help='List of related events eg. node_up, node_down') 14 | @click.option('--service', '-S', metavar='SERVICE', multiple=True, help='List of affected services eg. app name, Web, Network, Storage, Database, Security') 15 | @click.option('--group', '-g', metavar='GROUP', help='Group event by type eg. OS, Performance') 16 | @click.option('--value', '-v', metavar='VALUE', help='Event value') 17 | @click.option('--text', '-t', metavar='DESCRIPTION', help='Description of alert') 18 | @click.option('--tag', '-T', 'tags', multiple=True, metavar='TAG', help='List of tags eg. London, os:linux, AWS/EC2') 19 | @click.option('--attributes', '-A', multiple=True, metavar='KEY=VALUE', help='List of attributes eg. priority=high') 20 | @click.option('--origin', '-O', metavar='ORIGIN', help='Origin of alert in form app/host') 21 | @click.option('--type', metavar='EVENT_TYPE', help='Event type eg. exceptionAlert, performanceAlert, nagiosAlert') 22 | @click.option('--timeout', metavar='SECONDS', type=int, help='Seconds before an open alert will be expired') 23 | @click.option('--raw-data', metavar='STRING', help='Raw data of orignal alert eg. SNMP trap PDU. \'@\' to read from file, \'-\' to read from stdin') 24 | @click.option('--customer', metavar='STRING', help='Customer') 25 | @click.pass_obj 26 | def cli(obj, resource, event, environment, severity, correlate, service, group, value, text, tags, attributes, origin, type, timeout, raw_data, customer): 27 | """Send an alert.""" 28 | client = obj['client'] 29 | 30 | def send_alert(resource, event, **kwargs): 31 | try: 32 | id, alert, message = client.send_alert( 33 | resource=resource, 34 | event=event, 35 | environment=kwargs.get('environment'), 36 | severity=kwargs.get('severity'), 37 | correlate=kwargs.get('correlate', None) or list(), 38 | service=kwargs.get('service', None) or list(), 39 | group=kwargs.get('group'), 40 | value=kwargs.get('value'), 41 | text=kwargs.get('text'), 42 | tags=kwargs.get('tags', None) or list(), 43 | attributes=kwargs.get('attributes', None) or dict(), 44 | origin=kwargs.get('origin'), 45 | type=kwargs.get('type'), 46 | timeout=kwargs.get('timeout'), 47 | raw_data=kwargs.get('raw_data'), 48 | customer=kwargs.get('customer') 49 | ) 50 | except Exception as e: 51 | click.echo(f'ERROR: {e}', err=True) 52 | sys.exit(1) 53 | 54 | if alert: 55 | if alert.repeat: 56 | message = f'{alert.duplicate_count} duplicates' 57 | else: 58 | message = f'{alert.previous_severity} -> {alert.severity}' 59 | click.echo(f'{id} ({message})') 60 | 61 | # read raw data from file or stdin 62 | if raw_data and raw_data.startswith('@') or raw_data == '-': 63 | raw_data_file = raw_data.lstrip('@') 64 | with click.open_file(raw_data_file, 'r') as f: 65 | raw_data = f.read() 66 | 67 | # read entire alert object from terminal stdin 68 | elif not sys.stdin.isatty() and (os.environ.get('TERM', None) or os.environ.get('PS1', None)): 69 | with click.get_text_stream('stdin') as stdin: 70 | for line in stdin.readlines(): 71 | try: 72 | payload = json.loads(line) 73 | except Exception as e: 74 | click.echo(f"ERROR: JSON parse failure - input must be in 'json_lines' format: {e}", err=True) 75 | sys.exit(1) 76 | send_alert(**payload) 77 | sys.exit(0) 78 | 79 | send_alert( 80 | resource=resource, 81 | event=event, 82 | environment=environment, 83 | severity=severity, 84 | correlate=correlate, 85 | service=service, 86 | group=group, 87 | value=value, 88 | text=text, 89 | tags=tags, 90 | attributes=dict(a.split('=', maxsplit=1) if '=' in a else (a, None) for a in attributes), 91 | origin=origin, 92 | type=type, 93 | timeout=timeout, 94 | raw_data=raw_data, 95 | customer=customer 96 | ) 97 | -------------------------------------------------------------------------------- /alertaclient/models/blackout.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from alertaclient.utils import DateTime 4 | 5 | 6 | class Blackout: 7 | 8 | def __init__(self, environment, **kwargs): 9 | if not environment: 10 | raise ValueError('Missing mandatory value for "environment"') 11 | 12 | start_time = kwargs.get('start_time', None) or datetime.utcnow() 13 | if kwargs.get('end_time', None): 14 | end_time = kwargs.get('end_time') 15 | duration = int((end_time - start_time).total_seconds()) 16 | else: 17 | duration = kwargs.get('duration', None) 18 | end_time = start_time + timedelta(seconds=duration) 19 | 20 | self.id = kwargs.get('id', None) 21 | self.environment = environment 22 | self.service = kwargs.get('service', None) or list() 23 | self.resource = kwargs.get('resource', None) 24 | self.event = kwargs.get('event', None) 25 | self.group = kwargs.get('group', None) 26 | self.tags = kwargs.get('tags', None) or list() 27 | self.origin = kwargs.get('origin', None) 28 | self.customer = kwargs.get('customer', None) 29 | self.start_time = start_time 30 | self.end_time = end_time 31 | self.duration = duration 32 | 33 | self.user = kwargs.get('user', None) 34 | self.create_time = kwargs.get('create_time', None) 35 | self.text = kwargs.get('text', None) 36 | 37 | if self.environment: 38 | self.priority = 1 39 | if self.resource and not self.event: 40 | self.priority = 2 41 | elif self.service: 42 | self.priority = 3 43 | elif self.event and not self.resource: 44 | self.priority = 4 45 | elif self.group: 46 | self.priority = 5 47 | elif self.resource and self.event: 48 | self.priority = 6 49 | elif self.tags: 50 | self.priority = 7 51 | if self.origin: 52 | self.priority = 8 53 | 54 | now = datetime.utcnow() 55 | if self.start_time <= now and self.end_time > now: 56 | self.status = 'active' 57 | self.remaining = int((self.end_time - now).total_seconds()) 58 | elif self.start_time > now: 59 | self.status = 'pending' 60 | self.remaining = self.duration 61 | elif self.end_time <= now: 62 | self.status = 'expired' 63 | self.remaining = 0 64 | 65 | def __repr__(self): 66 | more = '' 67 | if self.service: 68 | more += 'service=%r, ' % self.service 69 | if self.resource: 70 | more += 'resource=%r, ' % self.resource 71 | if self.event: 72 | more += 'event=%r, ' % self.event 73 | if self.group: 74 | more += 'group=%r, ' % self.group 75 | if self.tags: 76 | more += 'tags=%r, ' % self.tags 77 | if self.origin: 78 | more += 'origin=%r, ' % self.origin 79 | if self.customer: 80 | more += 'customer=%r, ' % self.customer 81 | 82 | return 'Blackout(id={!r}, priority={!r}, status={!r}, environment={!r}, {}start_time={!r}, end_time={!r}, remaining={!r})'.format( 83 | self.id, 84 | self.priority, 85 | self.status, 86 | self.environment, 87 | more, 88 | self.start_time, 89 | self.end_time, 90 | self.remaining 91 | ) 92 | 93 | @classmethod 94 | def parse(cls, json): 95 | if not isinstance(json.get('service', []), list): 96 | raise ValueError('service must be a list') 97 | if not isinstance(json.get('tags', []), list): 98 | raise ValueError('tags must be a list') 99 | 100 | return Blackout( 101 | id=json.get('id'), 102 | environment=json.get('environment'), 103 | service=json.get('service', list()), 104 | resource=json.get('resource', None), 105 | event=json.get('event', None), 106 | group=json.get('group', None), 107 | tags=json.get('tags', list()), 108 | origin=json.get('origin', None), 109 | customer=json.get('customer', None), 110 | start_time=DateTime.parse(json.get('startTime')), 111 | end_time=DateTime.parse(json.get('endTime')), 112 | duration=json.get('duration', None), 113 | user=json.get('user', None), 114 | create_time=DateTime.parse(json.get('createTime')), 115 | text=json.get('text', None) 116 | ) 117 | 118 | def tabular(self, timezone=None): 119 | return { 120 | 'id': self.id, 121 | 'priority': self.priority, 122 | 'environment': self.environment, 123 | 'service': ','.join(self.service), 124 | 'resource': self.resource, 125 | 'event': self.event, 126 | 'group': self.group, 127 | 'tags': ','.join(self.tags), 128 | 'origin': self.origin, 129 | 'customer': self.customer, 130 | 'startTime': DateTime.localtime(self.start_time, timezone), 131 | 'endTime': DateTime.localtime(self.end_time, timezone), 132 | 'duration': f'{self.duration}s', 133 | 'status': self.status, 134 | 'remaining': f'{self.remaining}s', 135 | 'user': self.user, 136 | 'createTime': DateTime.localtime(self.create_time, timezone), 137 | 'text': self.text 138 | } 139 | -------------------------------------------------------------------------------- /alertaclient/top.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import sys 3 | import time 4 | from curses import wrapper 5 | from datetime import datetime 6 | 7 | from alertaclient.models.alert import Alert 8 | from alertaclient.utils import DateTime 9 | 10 | 11 | class Screen: 12 | 13 | ALIGN_RIGHT = 'R' 14 | ALIGN_CENTRE = 'C' 15 | 16 | def __init__(self, client, timezone): 17 | self.client = client 18 | self.timezone = timezone 19 | 20 | self.screen = None 21 | self.lines = None 22 | self.cols = None 23 | 24 | def run(self): 25 | wrapper(self.main) 26 | 27 | def main(self, stdscr): 28 | self.screen = stdscr 29 | 30 | curses.use_default_colors() 31 | 32 | curses.init_pair(1, curses.COLOR_RED, -1) 33 | curses.init_pair(2, curses.COLOR_MAGENTA, -1) 34 | curses.init_pair(3, curses.COLOR_YELLOW, -1) 35 | curses.init_pair(4, curses.COLOR_BLUE, -1) 36 | curses.init_pair(5, curses.COLOR_CYAN, -1) 37 | curses.init_pair(6, curses.COLOR_GREEN, -1) 38 | curses.init_pair(7, curses.COLOR_WHITE, curses.COLOR_BLACK) 39 | 40 | COLOR_RED = curses.color_pair(1) 41 | COLOR_MAGENTA = curses.color_pair(2) 42 | COLOR_YELLOW = curses.color_pair(3) 43 | COLOR_BLUE = curses.color_pair(4) 44 | COLOR_CYAN = curses.color_pair(5) 45 | COLOR_GREEN = curses.color_pair(6) 46 | COLOR_BLACK = curses.color_pair(7) 47 | 48 | self.SEVERITY_MAP = { 49 | 'security': ['Sec', COLOR_BLACK], 50 | 'critical': ['Crit', COLOR_RED], 51 | 'major': ['Majr', COLOR_MAGENTA], 52 | 'minor': ['Minr', COLOR_YELLOW], 53 | 'warning': ['Warn', COLOR_BLUE], 54 | 'indeterminate': ['Ind ', COLOR_CYAN], 55 | 'cleared': ['Clr', COLOR_GREEN], 56 | 'normal': ['Norm', COLOR_GREEN], 57 | 'ok': ['Ok', COLOR_GREEN], 58 | 'informational': ['Info', COLOR_GREEN], 59 | 'debug': ['Dbug', COLOR_BLACK], 60 | 'trace': ['Trce', COLOR_BLACK], 61 | 'unknown': ['Unkn', COLOR_BLACK] 62 | } 63 | 64 | self.screen.keypad(1) 65 | self.screen.nodelay(1) 66 | 67 | while True: 68 | self.update() 69 | event = self.screen.getch() 70 | if 0 < event < 256: 71 | self._key_press(chr(event)) 72 | else: 73 | if event == curses.KEY_RESIZE: 74 | self.update() 75 | time.sleep(2) 76 | 77 | def update(self): 78 | self.lines, self.cols = self.screen.getmaxyx() 79 | self.screen.clear() 80 | 81 | now = datetime.utcnow() 82 | status = self.client.mgmt_status() 83 | version = status['version'] 84 | 85 | # draw header 86 | self._addstr(0, 0, self.client.endpoint, curses.A_BOLD) 87 | self._addstr(0, 'C', f'alerta {version}', curses.A_BOLD) 88 | self._addstr(0, 'R', '{}'.format(now.strftime('%H:%M:%S %d/%m/%y')), curses.A_BOLD) 89 | 90 | # TODO - draw bars 91 | 92 | # draw alerts 93 | text_width = self.cols - 95 if self.cols >= 95 else 0 94 | self._addstr(2, 1, 'Sev. Time Dupl. Customer Env. Service Resource Group Event' 95 | + ' Value Text' + ' ' * (text_width - 4), curses.A_UNDERLINE) 96 | 97 | def short_sev(severity): 98 | return self.SEVERITY_MAP.get(severity, self.SEVERITY_MAP['unknown'])[0] 99 | 100 | def color(severity): 101 | return self.SEVERITY_MAP.get(severity, self.SEVERITY_MAP['unknown'])[1] 102 | 103 | r = self.client.http.get('/alerts') 104 | alerts = [Alert.parse(a) for a in r['alerts']] 105 | last_time = DateTime.parse(r['lastTime']) 106 | 107 | for i, alert in enumerate(alerts): 108 | row = i + 3 109 | if row >= self.lines - 2: # leave room for footer 110 | break 111 | 112 | text = '{:<4} {} {:5d} {:8.8} {:<12} {:<12} {:<12.12} {:5.5} {:<12.12} {:<5.5} {:.{width}}'.format( 113 | short_sev(alert.severity), 114 | DateTime.localtime(alert.last_receive_time, self.timezone, fmt='%H:%M:%S'), 115 | alert.duplicate_count, 116 | alert.customer or '-', 117 | alert.environment, 118 | ','.join(alert.service), 119 | alert.resource, 120 | alert.group, 121 | alert.event, 122 | alert.value or 'n/a', 123 | alert.text, 124 | width=text_width 125 | ) 126 | # XXX - needed to support python2 and python3 127 | if not isinstance(text, str): 128 | text = text.encode('ascii', errors='replace') 129 | 130 | self._addstr(row, 1, text, color(alert.severity)) 131 | 132 | # draw footer 133 | self._addstr(self.lines - 1, 0, 'Last Update: {}'.format(last_time.strftime('%H:%M:%S')), curses.A_BOLD) 134 | self._addstr(self.lines - 1, 'C', '{} - {}'.format(r['status'], r.get('message', 'no errors')), curses.A_BOLD) 135 | self._addstr(self.lines - 1, 'R', 'Count: {}'.format(r['total']), curses.A_BOLD) 136 | 137 | self.screen.refresh() 138 | 139 | def _addstr(self, y, x, line, attr=0): 140 | if x == self.ALIGN_RIGHT: 141 | x = self.cols - len(line) - 1 142 | if x == self.ALIGN_CENTRE: 143 | x = int((self.cols / 2) - len(line) / 2) 144 | 145 | self.screen.addstr(y, x, line, attr) 146 | 147 | def _key_press(self, key): 148 | if key in 'qQ': 149 | sys.exit(0) 150 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_heartbeats.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | from alertaclient.models.heartbeat import Heartbeat 7 | from alertaclient.utils import origin 8 | 9 | 10 | @click.command('heartbeats', short_help='List heartbeats') 11 | @click.option('--alert', is_flag=True, help='Alert on stale or slow heartbeats') 12 | @click.option('--severity', '-s', metavar='SEVERITY', default='major', help='Severity for heartbeat alerts') 13 | @click.option('--timeout', metavar='SECONDS', type=int, help='Seconds before stale heartbeat alerts will be expired') 14 | @click.option('--purge', is_flag=True, help='Delete all stale heartbeats') 15 | @click.pass_obj 16 | def cli(obj, alert, severity, timeout, purge): 17 | """List heartbeats.""" 18 | client = obj['client'] 19 | 20 | try: 21 | default_normal_severity = obj['alarm_model']['defaults']['normal_severity'] 22 | except KeyError: 23 | default_normal_severity = 'normal' 24 | 25 | if severity in ['normal', 'ok', 'cleared']: 26 | raise click.UsageError('Must be a non-normal severity. "{}" is one of {}'.format( 27 | severity, ', '.join(['normal', 'ok', 'cleared'])) 28 | ) 29 | 30 | if severity not in obj['alarm_model']['severity'].keys(): 31 | raise click.UsageError('Must be a valid severity. "{}" is not one of {}'.format( 32 | severity, ', '.join(obj['alarm_model']['severity'].keys())) 33 | ) 34 | 35 | if obj['output'] == 'json': 36 | r = client.http.get('/heartbeats') 37 | heartbeats = [Heartbeat.parse(hb) for hb in r['heartbeats']] 38 | click.echo(json.dumps(r['heartbeats'], sort_keys=True, indent=4, ensure_ascii=False)) 39 | else: 40 | timezone = obj['timezone'] 41 | headers = { 42 | 'id': 'ID', 'origin': 'ORIGIN', 'customer': 'CUSTOMER', 'tags': 'TAGS', 'attributes': 'ATTRIBUTES', 43 | 'createTime': 'CREATED', 'receiveTime': 'RECEIVED', 'since': 'SINCE', 'timeout': 'TIMEOUT', 44 | 'latency': 'LATENCY', 'maxLatency': 'MAX LATENCY', 'status': 'STATUS' 45 | } 46 | heartbeats = client.get_heartbeats() 47 | click.echo(tabulate([h.tabular(timezone) for h in heartbeats], headers=headers, tablefmt=obj['output'])) 48 | 49 | not_ok = [hb for hb in heartbeats if hb.status != 'ok'] 50 | if purge: 51 | with click.progressbar(not_ok, label=f'Purging {len(not_ok)} heartbeats') as bar: 52 | for b in bar: 53 | client.delete_heartbeat(b.id) 54 | 55 | if alert: 56 | with click.progressbar(heartbeats, label=f'Alerting {len(heartbeats)} heartbeats') as bar: 57 | for b in bar: 58 | 59 | want_environment = b.attributes.pop('environment', 'Production') 60 | want_severity = b.attributes.pop('severity', severity) 61 | want_service = b.attributes.pop('service', ['Alerta']) 62 | want_group = b.attributes.pop('group', 'System') 63 | 64 | if b.status == 'expired': # aka. "stale" 65 | client.send_alert( 66 | resource=b.origin, 67 | event='HeartbeatFail', 68 | environment=want_environment, 69 | severity=want_severity, 70 | correlate=['HeartbeatFail', 'HeartbeatSlow', 'HeartbeatOK'], 71 | service=want_service, 72 | group=want_group, 73 | value=f'{b.since}', 74 | text=f'Heartbeat not received in {b.timeout} seconds', 75 | tags=b.tags, 76 | attributes=b.attributes, 77 | origin=origin(), 78 | type='heartbeatAlert', 79 | timeout=timeout, 80 | customer=b.customer 81 | ) 82 | elif b.status == 'slow': 83 | client.send_alert( 84 | resource=b.origin, 85 | event='HeartbeatSlow', 86 | environment=want_environment, 87 | severity=want_severity, 88 | correlate=['HeartbeatFail', 'HeartbeatSlow', 'HeartbeatOK'], 89 | service=want_service, 90 | group=want_group, 91 | value=f'{b.latency}ms', 92 | text=f'Heartbeat took more than {b.max_latency}ms to be processed', 93 | tags=b.tags, 94 | attributes=b.attributes, 95 | origin=origin(), 96 | type='heartbeatAlert', 97 | timeout=timeout, 98 | customer=b.customer 99 | ) 100 | else: 101 | client.send_alert( 102 | resource=b.origin, 103 | event='HeartbeatOK', 104 | environment=want_environment, 105 | severity=default_normal_severity, 106 | correlate=['HeartbeatFail', 'HeartbeatSlow', 'HeartbeatOK'], 107 | service=want_service, 108 | group=want_group, 109 | value='', 110 | text='Heartbeat OK', 111 | tags=b.tags, 112 | attributes=b.attributes, 113 | origin=origin(), 114 | type='heartbeatAlert', 115 | timeout=timeout, 116 | customer=b.customer 117 | ) 118 | -------------------------------------------------------------------------------- /wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi 183 | -------------------------------------------------------------------------------- /alertaclient/models/alert.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from alertaclient.utils import DateTime 4 | 5 | 6 | class Alert: 7 | 8 | def __init__(self, resource, event, **kwargs): 9 | if not resource: 10 | raise ValueError('Missing mandatory value for "resource"') 11 | if not event: 12 | raise ValueError('Missing mandatory value for "event"') 13 | if any(['.' in key for key in kwargs.get('attributes', dict()).keys()])\ 14 | or any(['$' in key for key in kwargs.get('attributes', dict()).keys()]): 15 | raise ValueError('Attribute keys must not contain "." or "$"') 16 | 17 | self.id = kwargs.get('id', None) 18 | self.resource = resource 19 | self.event = event 20 | self.environment = kwargs.get('environment', None) or '' 21 | self.severity = kwargs.get('severity', None) 22 | self.correlate = kwargs.get('correlate', None) or list() 23 | if self.correlate and event not in self.correlate: 24 | self.correlate.append(event) 25 | self.status = kwargs.get('status', None) or 'unknown' 26 | self.service = kwargs.get('service', None) or list() 27 | self.group = kwargs.get('group', None) or 'Misc' 28 | self.value = kwargs.get('value', None) 29 | self.text = kwargs.get('text', None) or '' 30 | self.tags = kwargs.get('tags', None) or list() 31 | self.attributes = kwargs.get('attributes', None) or dict() 32 | self.origin = kwargs.get('origin', None) 33 | self.event_type = kwargs.get('event_type', kwargs.get('type', None)) or 'exceptionAlert' 34 | self.create_time = kwargs.get('create_time', None) or datetime.utcnow() 35 | self.timeout = kwargs.get('timeout', None) 36 | self.raw_data = kwargs.get('raw_data', None) 37 | self.customer = kwargs.get('customer', None) 38 | 39 | self.duplicate_count = kwargs.get('duplicate_count', None) 40 | self.repeat = kwargs.get('repeat', None) 41 | self.previous_severity = kwargs.get('previous_severity', None) 42 | self.trend_indication = kwargs.get('trend_indication', None) 43 | self.receive_time = kwargs.get('receive_time', None) or datetime.utcnow() 44 | self.last_receive_id = kwargs.get('last_receive_id', None) 45 | self.last_receive_time = kwargs.get('last_receive_time', None) 46 | self.history = kwargs.get('history', None) or list() 47 | 48 | def __repr__(self): 49 | return 'Alert(id={!r}, environment={!r}, resource={!r}, event={!r}, severity={!r}, status={!r}, customer={!r})'.format( 50 | self.id, self.environment, self.resource, self.event, self.severity, self.status, self.customer) 51 | 52 | @classmethod 53 | def parse(cls, json): 54 | if not isinstance(json.get('correlate', []), list): 55 | raise ValueError('correlate must be a list') 56 | if not isinstance(json.get('service', []), list): 57 | raise ValueError('service must be a list') 58 | if not isinstance(json.get('tags', []), list): 59 | raise ValueError('tags must be a list') 60 | if not isinstance(json.get('attributes', {}), dict): 61 | raise ValueError('attributes must be a JSON object') 62 | if not isinstance(json.get('timeout') if json.get('timeout', None) is not None else 0, int): 63 | raise ValueError('timeout must be an integer') 64 | 65 | return Alert( 66 | id=json.get('id', None), 67 | resource=json.get('resource', None), 68 | event=json.get('event', None), 69 | environment=json.get('environment', None), 70 | severity=json.get('severity', None), 71 | correlate=json.get('correlate', list()), 72 | status=json.get('status', None), 73 | service=json.get('service', list()), 74 | group=json.get('group', None), 75 | value=json.get('value', None), 76 | text=json.get('text', None), 77 | tags=json.get('tags', list()), 78 | attributes=json.get('attributes', dict()), 79 | origin=json.get('origin', None), 80 | event_type=json.get('type', None), 81 | create_time=DateTime.parse(json.get('createTime')), 82 | timeout=json.get('timeout', None), 83 | raw_data=json.get('rawData', None), 84 | customer=json.get('customer', None), 85 | 86 | duplicate_count=json.get('duplicateCount', None), 87 | repeat=json.get('repeat', None), 88 | previous_severity=json.get('previousSeverity', None), 89 | trend_indication=json.get('trendIndication', None), 90 | receive_time=DateTime.parse(json.get('receiveTime')), 91 | last_receive_id=json.get('lastReceiveId', None), 92 | last_receive_time=DateTime.parse(json.get('lastReceiveTime')), 93 | history=json.get('history', None) 94 | ) 95 | 96 | def get_id(self, short=False): 97 | return self.id[:8] if short else self.id 98 | 99 | def tabular(self, timezone=None): 100 | return { 101 | 'id': self.get_id(short=True), 102 | 'lastReceiveTime': DateTime.localtime(self.last_receive_time, timezone), 103 | 'severity': self.severity, 104 | 'status': self.status, 105 | 'duplicateCount': self.duplicate_count, 106 | 'customer': self.customer, 107 | 'environment': self.environment, 108 | 'service': ','.join(self.service), 109 | 'resource': self.resource, 110 | 'group': self.group, 111 | 'event': self.event, 112 | 'correlate': self.correlate, 113 | 'value': self.value, 114 | 'text': self.text, 115 | 'tags': ','.join(self.tags), 116 | 'attributes': self.attributes, 117 | 'origin': self.origin, 118 | 'type': self.event_type, 119 | 'createTime': DateTime.localtime(self.create_time, timezone), 120 | 'timeout': self.timeout, 121 | 'rawData': self.raw_data, 122 | 'repeat': self.repeat, 123 | 'previousSeverity': self.previous_severity, 124 | 'trendIndication': self.trend_indication, 125 | 'receiveTime': DateTime.localtime(self.receive_time, timezone), 126 | 'lastReceiveId': self.last_receive_id, 127 | 'history': self.history 128 | } 129 | -------------------------------------------------------------------------------- /alertaclient/commands/cmd_query.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import click 4 | from tabulate import tabulate 5 | 6 | from alertaclient.models.alert import Alert 7 | from alertaclient.utils import DateTime, build_query 8 | 9 | COLOR_MAP = { 10 | 'critical': {'fg': 'red'}, 11 | 'major': {'fg': 'magenta'}, 12 | 'minor': {'fg': 'yellow'}, 13 | 'warning': {'fg': 'blue'}, 14 | 'normal': {'fg': 'green'}, 15 | 'indeterminate': {'fg': 'cyan'}, 16 | } 17 | 18 | 19 | @click.command('query', short_help='Search for alerts') 20 | @click.option('--ids', '-i', metavar='ID', multiple=True, help='List of alert IDs (can use short 8-char id)') 21 | @click.option('--query', '-q', 'query', metavar='QUERY', help='severity:"warning" AND resource:web') 22 | @click.option('--filter', '-f', 'filters', metavar='FILTER', multiple=True, help='KEY=VALUE eg. serverity=warning resource=web') 23 | @click.option('--oneline', 'display', flag_value='oneline', default=True, help='Show alerts using table format') 24 | @click.option('--medium', 'display', flag_value='medium', help='Show important alert attributes') 25 | @click.option('--full', 'display', flag_value='full', help='Show full alert details') 26 | @click.pass_obj 27 | def cli(obj, ids, query, filters, display, from_date=None): 28 | """Query for alerts based on search filter criteria.""" 29 | client = obj['client'] 30 | timezone = obj['timezone'] 31 | if ids: 32 | query = [('id', x) for x in ids] 33 | elif query: 34 | query = [('q', query)] 35 | else: 36 | query = build_query(filters) 37 | if from_date: 38 | query.append(('from-date', from_date)) 39 | 40 | r = client.http.get('/alerts', query, page=1, page_size=1000) 41 | 42 | if obj['output'] == 'json': 43 | click.echo(json.dumps(r['alerts'], sort_keys=True, indent=4, ensure_ascii=False)) 44 | elif obj['output'] in ['json_lines', 'jsonl', 'ndjson']: 45 | for alert in r['alerts']: 46 | click.echo(json.dumps(alert, ensure_ascii=False)) 47 | else: 48 | alerts = [Alert.parse(a) for a in r['alerts']] 49 | last_time = r['lastTime'] 50 | auto_refresh = r['autoRefresh'] 51 | 52 | if display == 'oneline': 53 | headers = {'id': 'ID', 'lastReceiveTime': 'LAST RECEIVED', 'severity': 'SEVERITY', 'status': 'STATUS', 54 | 'duplicateCount': 'DUPL', 'customer': 'CUSTOMER', 'environment': 'ENVIRONMENT', 'service': 'SERVICE', 55 | 'resource': 'RESOURCE', 'group': 'GROUP', 'event': 'EVENT', 'value': 'VALUE', 'text': 'DESCRIPTION'} 56 | 57 | data = [{k: v for k, v in a.tabular(timezone).items() if k in headers.keys()} for a in alerts] 58 | click.echo(tabulate(data, headers=headers, tablefmt=obj['output'])) 59 | 60 | else: 61 | for alert in reversed(alerts): 62 | color = COLOR_MAP.get(alert.severity, {'fg': 'white'}) 63 | click.secho('{}|{}|{}|{:5d}|{}|{:<5s}|{:<10s}|{:<18s}|{:12s}|{:16s}|{:12s}'.format( 64 | alert.id[0:8], 65 | DateTime.localtime(alert.last_receive_time, timezone), 66 | alert.severity, 67 | alert.duplicate_count, 68 | alert.customer or '-', 69 | alert.environment, 70 | ','.join(alert.service), 71 | alert.resource, 72 | alert.group, 73 | alert.event, 74 | alert.value or 'n/a'), fg=color['fg']) 75 | click.secho(f' |{alert.text}', fg=color['fg']) 76 | 77 | if display == 'full': 78 | click.secho(' severity | {} -> {}'.format(alert.previous_severity, 79 | alert.severity), fg=color['fg']) 80 | click.secho(f' trend | {alert.trend_indication}', fg=color['fg']) 81 | click.secho(f' status | {alert.status}', fg=color['fg']) 82 | click.secho(f' resource | {alert.resource}', fg=color['fg']) 83 | click.secho(f' group | {alert.group}', fg=color['fg']) 84 | click.secho(f' event | {alert.event}', fg=color['fg']) 85 | click.secho(f' value | {alert.value}', fg=color['fg']) 86 | click.secho(' tags | {}'.format(' '.join(alert.tags)), fg=color['fg']) 87 | 88 | for key, value in alert.attributes.items(): 89 | click.secho(f' {key.ljust(10)} | {value}', fg=color['fg']) 90 | 91 | latency = alert.receive_time - alert.create_time 92 | 93 | click.secho(' time created | {}'.format( 94 | DateTime.localtime(alert.create_time, timezone)), fg=color['fg']) 95 | click.secho(' time received | {}'.format( 96 | DateTime.localtime(alert.receive_time, timezone)), fg=color['fg']) 97 | click.secho(' last received | {}'.format( 98 | DateTime.localtime(alert.last_receive_time, timezone)), fg=color['fg']) 99 | click.secho(f' latency | {latency.microseconds / 1000}ms', fg=color['fg']) 100 | click.secho(f' timeout | {alert.timeout}s', fg=color['fg']) 101 | 102 | click.secho(f' alert id | {alert.id}', fg=color['fg']) 103 | click.secho(f' last recv id | {alert.last_receive_id}', fg=color['fg']) 104 | click.secho(f' customer | {alert.customer}', fg=color['fg']) 105 | click.secho(f' environment | {alert.environment}', fg=color['fg']) 106 | click.secho(' service | {}'.format(','.join(alert.service)), fg=color['fg']) 107 | click.secho(f' resource | {alert.resource}', fg=color['fg']) 108 | click.secho(f' type | {alert.event_type}', fg=color['fg']) 109 | click.secho(f' repeat | {alert.repeat}', fg=color['fg']) 110 | click.secho(f' origin | {alert.origin}', fg=color['fg']) 111 | click.secho(' correlate | {}'.format(','.join(alert.correlate)), fg=color['fg']) 112 | 113 | return auto_refresh, last_time 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Alerta Command-Line Tool 2 | ======================== 3 | 4 | [![Actions Status](https://github.com/alerta/python-alerta-client/workflows/CI%20Tests/badge.svg)](https://github.com/alerta/python-alerta-client/actions) 5 | [![Slack chat](https://img.shields.io/badge/chat-on%20slack-blue?logo=slack)](https://slack.alerta.dev) [![Coverage Status](https://coveralls.io/repos/github/alerta/python-alerta-client/badge.svg?branch=master)](https://coveralls.io/github/alerta/python-alerta-client?branch=master) 6 | 7 | Unified command-line tool, terminal GUI and python SDK for the Alerta monitoring system. 8 | 9 | ![screen shot](/docs/images/alerta-top-80x25.png?raw=true&v=1) 10 | 11 | Related projects can be found on the Alerta Org Repo at . 12 | 13 | Installation 14 | ------------ 15 | 16 | To install the Alerta CLI tool run:: 17 | 18 | $ pip install alerta 19 | 20 | Configuration 21 | ------------- 22 | 23 | Options can be set in a configuration file, as environment variables or on the command line. 24 | Profiles can be used to easily switch between different configuration settings. 25 | 26 | | Option | Config File | Environment Variable | Optional Argument | Default | 27 | |-------------------|-------------|----------------------------|---------------------------------|---------------------------| 28 | | file | n/a | ``ALERTA_CONF_FILE`` | n/a | ``~/.alerta.conf`` | 29 | | profile | profile | ``ALERTA_DEFAULT_PROFILE`` | ``--profile PROFILE`` | None | 30 | | endpoint | endpoint | ``ALERTA_ENDPOINT`` | ``--endpoint-url URL`` | ``http://localhost:8080`` | 31 | | key | key | ``ALERTA_API_KEY`` | n/a | None | 32 | | timezone | timezone | n/a | n/a | Europe/London | 33 | | SSL verify | sslverify | ``REQUESTS_CA_BUNDLE`` | n/a | verify SSL certificates | 34 | | SSL client cert | sslcert | n/a | n/a | None | 35 | | SSL client key | sslkey | n/a | n/a | None | 36 | | timeout | timeout | n/a | n/a | 5s TCP connection timeout | 37 | | output | output | n/a | ``--output-format OUTPUT`` | simple | 38 | | color | color | ``CLICOLOR`` | ``--color``, ``--no-color`` | color on | 39 | | debug | debug | ``DEBUG`` | ``--debug`` | no debug | 40 | 41 | Example 42 | ------- 43 | 44 | Configuration file ``~/.alerta.conf``:: 45 | 46 | [DEFAULT] 47 | timezone = Australia/Sydney 48 | # output = psql 49 | profile = production 50 | 51 | [profile production] 52 | endpoint = https://api.alerta.io 53 | key = demo-key 54 | 55 | [profile development] 56 | endpoint = https://localhost:8443 57 | sslverify = off 58 | timeout = 10.0 59 | debug = yes 60 | 61 | Environment Variables 62 | --------------------- 63 | 64 | Set environment variables to use production configuration settings by default:: 65 | 66 | $ export ALERTA_CONF_FILE=~/.alerta.conf 67 | $ export ALERTA_DEFAULT_PROFILE=production 68 | 69 | $ alerta query 70 | 71 | And to switch to development configuration settings when required use the ``--profile`` option:: 72 | 73 | $ alerta --profile development query 74 | 75 | Usage 76 | ----- 77 | 78 | $ alerta 79 | Usage: alerta [OPTIONS] COMMAND [ARGS]... 80 | 81 | Alerta client unified command-line tool. 82 | 83 | Options: 84 | --config-file Configuration file. 85 | --profile Configuration profile. 86 | --endpoint-url API endpoint URL. 87 | --output-format Output format. eg. simple, grid, psql, presto, rst 88 | --color / --no-color Color-coded output based on severity. 89 | --debug Debug mode. 90 | --help Show this message and exit. 91 | 92 | Commands: 93 | ack Acknowledge alerts 94 | blackout Suppress alerts 95 | blackouts List alert suppressions 96 | close Close alerts 97 | customer Add customer lookup 98 | customers List customer lookups 99 | delete Delete alerts 100 | heartbeat Send a heartbeat 101 | heartbeats List heartbeats 102 | help Show this help 103 | history Show alert history 104 | key Create API key 105 | keys List API keys 106 | login Login with user credentials 107 | logout Clear login credentials 108 | perm Add role-permission lookup 109 | perms List role-permission lookups 110 | query Search for alerts 111 | raw Show alert raw data 112 | revoke Revoke API key 113 | send Send an alert 114 | status Display status and metrics 115 | tag Tag alerts 116 | token Display current auth token 117 | unack Un-acknowledge alerts 118 | untag Untag alerts 119 | update Update alert attributes 120 | uptime Display server uptime 121 | user Update user 122 | users List users 123 | version Display version info 124 | whoami Display current logged in user 125 | 126 | Python SDK 127 | ========== 128 | 129 | The alerta client python package can also be used as a Python SDK. 130 | 131 | Example 132 | ------- 133 | 134 | >>> from alertaclient.api import Client 135 | 136 | >>> client = Client(key='NGLxwf3f4-8LlYN4qLjVEagUPsysn0kb9fAkAs1l') 137 | >>> client.send_alert(environment='Production', service=['Web', 'Application'], resource='web01', event='HttpServerError', value='501', text='Web server unavailable.') 138 | Alert(id='42254ef8-7258-4300-aaec-a9ad7d3a84ff', environment='Production', resource='web01', event='HttpServerError', severity='normal', status='closed', customer=None) 139 | 140 | >>> [a.id for a in client.search([('resource','~we.*01'), ('environment!', 'Development')])] 141 | ['42254ef8-7258-4300-aaec-a9ad7d3a84ff'] 142 | 143 | >>> client.heartbeat().serialize()['status'] 144 | 'ok' 145 | 146 | License 147 | ------- 148 | 149 | Alerta monitoring system and console 150 | Copyright 2012-2024 Nick Satterly 151 | 152 | Licensed under the Apache License, Version 2.0 (the "License"); 153 | you may not use this file except in compliance with the License. 154 | You may obtain a copy of the License at 155 | 156 | http://www.apache.org/licenses/LICENSE-2.0 157 | 158 | Unless required by applicable law or agreed to in writing, software 159 | distributed under the License is distributed on an "AS IS" BASIS, 160 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 161 | See the License for the specific language governing permissions and 162 | limitations under the License. 163 | -------------------------------------------------------------------------------- /tests/unit/test_commands.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from uuid import UUID 3 | 4 | import requests_mock 5 | from click.testing import CliRunner 6 | 7 | from alertaclient.api import Client 8 | from alertaclient.commands.cmd_heartbeat import cli as heartbeat_cmd 9 | from alertaclient.commands.cmd_heartbeats import cli as heartbeats_cmd 10 | from alertaclient.commands.cmd_whoami import cli as whoami_cmd 11 | from alertaclient.config import Config 12 | 13 | 14 | class CommandsTestCase(unittest.TestCase): 15 | 16 | def setUp(self): 17 | self.client = Client() 18 | 19 | alarm_model = { 20 | 'name': 'Alerta 8.0.1', 21 | 'severity': { 22 | 'security': 0, 23 | 'critical': 1, 24 | 'major': 2, 25 | 'minor': 3, 26 | 'warning': 4, 27 | 'indeterminate': 5, 28 | 'informational': 6, 29 | 'normal': 7, 30 | 'ok': 7, 31 | 'cleared': 7, 32 | 'debug': 8, 33 | 'trace': 9, 34 | 'unknown': 10 35 | }, 36 | 'defaults': { 37 | 'normal_severity': 'normal' 38 | } 39 | } 40 | 41 | config = Config(config_file=None, config_override={'alarm_model': alarm_model}) 42 | self.obj = config.options 43 | self.obj['client'] = self.client 44 | 45 | self.runner = CliRunner(echo_stdin=True) 46 | 47 | @requests_mock.mock() 48 | def test_heartbeat_cmd(self, m): 49 | 50 | heartbeat_response = """ 51 | { 52 | "heartbeat": { 53 | "attributes": { 54 | "environment": "Production", 55 | "service": [ 56 | "Web" 57 | ], 58 | "severity": "major" 59 | }, 60 | "createTime": "2020-01-25T12:32:50.223Z", 61 | "customer": null, 62 | "href": "http://api.local.alerta.io:8080/heartbeat/e07d7c02-0b41-418a-b0e6-cd172e06c872", 63 | "id": "e07d7c02-0b41-418a-b0e6-cd172e06c872", 64 | "latency": 14, 65 | "maxLatency": 2000, 66 | "origin": "alerta/macbook.lan", 67 | "receiveTime": "2020-01-25T12:32:50.237Z", 68 | "since": 0, 69 | "status": "ok", 70 | "tags": [], 71 | "timeout": 86400, 72 | "type": "Heartbeat" 73 | }, 74 | "id": "e07d7c02-0b41-418a-b0e6-cd172e06c872", 75 | "status": "ok" 76 | } 77 | """ 78 | 79 | m.post('/heartbeat', text=heartbeat_response) 80 | result = self.runner.invoke(heartbeat_cmd, ['-E', 'Production', '-S', 'Web', '-s', 'major'], obj=self.obj) 81 | UUID(result.output.strip()) 82 | self.assertEqual(result.exit_code, 0) 83 | 84 | @requests_mock.mock() 85 | def test_heartbeats_cmd(self, m): 86 | 87 | heartbeats_response = """ 88 | { 89 | "heartbeats": [ 90 | { 91 | "attributes": { 92 | "environment": "Infrastructure", 93 | "severity": "major", 94 | "service": ["Internal"], 95 | "group": "Heartbeats", 96 | "region": "EU" 97 | }, 98 | "createTime": "2020-03-10T20:25:54.541Z", 99 | "customer": null, 100 | "href": "http://127.0.0.1/heartbeat/52c202e8-d949-45ed-91e0-cdad4f37de73", 101 | "id": "52c202e8-d949-45ed-91e0-cdad4f37de73", 102 | "latency": 0, 103 | "maxLatency": 2000, 104 | "origin": "monitoring-01", 105 | "receiveTime": "2020-03-10T20:25:54.541Z", 106 | "since": 204, 107 | "status": "expired", 108 | "tags": [], 109 | "timeout": 90, 110 | "type": "Heartbeat" 111 | } 112 | ], 113 | "status": "ok", 114 | "total": 1 115 | } 116 | """ 117 | 118 | heartbeat_alert_response = """ 119 | { 120 | "alert": { 121 | "attributes": {}, 122 | "correlate": [ 123 | "HeartbeatFail", 124 | "HeartbeatSlow", 125 | "HeartbeatOK" 126 | ], 127 | "createTime": "2020-03-10T21:55:07.884Z", 128 | "customer": null, 129 | "duplicateCount": 0, 130 | "environment": "Infrastructure", 131 | "event": "HeartbeatSlow", 132 | "group": "Heartbeat", 133 | "history": [ 134 | { 135 | "event": "HeartbeatSlow", 136 | "href": "http://api.local.alerta.io:8080/alert/6cfbc30f-c2d6-4edf-b672-841070995206", 137 | "id": "6cfbc30f-c2d6-4edf-b672-841070995206", 138 | "severity": "warning", 139 | "status": "open", 140 | "text": "new alert", 141 | "type": "new", 142 | "updateTime": "2020-03-10T21:55:07.884Z", 143 | "user": null, 144 | "value": "22ms" 145 | } 146 | ], 147 | "href": "http://api.local.alerta.io:8080/alert/6cfbc30f-c2d6-4edf-b672-841070995206", 148 | "id": "6cfbc30f-c2d6-4edf-b672-841070995206", 149 | "lastReceiveId": "6cfbc30f-c2d6-4edf-b672-841070995206", 150 | "lastReceiveTime": "2020-03-10T21:55:07.916Z", 151 | "origin": "alerta/macbook.lan", 152 | "previousSeverity": "indeterminate", 153 | "rawData": null, 154 | "receiveTime": "2020-03-10T21:55:07.916Z", 155 | "repeat": false, 156 | "resource": "monitoring-01", 157 | "service": [ 158 | "Internal" 159 | ], 160 | "severity": "warning", 161 | "status": "open", 162 | "tags": [], 163 | "text": "Heartbeat took more than 2ms to be processed", 164 | "timeout": 86000, 165 | "trendIndication": "moreSevere", 166 | "type": "heartbeatAlert", 167 | "updateTime": "2020-03-10T21:55:07.916Z", 168 | "value": "22ms" 169 | }, 170 | "id": "6cfbc30f-c2d6-4edf-b672-841070995206", 171 | "status": "ok" 172 | } 173 | """ 174 | 175 | m.get('/heartbeats', text=heartbeats_response) 176 | m.post('/alert', text=heartbeat_alert_response) 177 | result = self.runner.invoke(heartbeats_cmd, ['--alert'], obj=self.obj) 178 | self.assertEqual(result.exit_code, 0, result.exception) 179 | self.assertIn('monitoring-01', result.output) 180 | 181 | history = m.request_history 182 | data = history[1].json() 183 | self.assertEqual(data['environment'], 'Infrastructure') 184 | self.assertEqual(data['severity'], 'major') 185 | self.assertEqual(data['service'], ['Internal']) 186 | self.assertEqual(data['group'], 'Heartbeats') 187 | self.assertEqual(data['attributes'], {'region': 'EU'}) 188 | 189 | @requests_mock.mock() 190 | def test_whoami_cmd(self, m): 191 | 192 | whoami_response = """ 193 | { 194 | "aud": "736147134702-glkb1pesv716j1utg4llg7c3rr7nnhli.apps.googleusercontent.com", 195 | "customers": [], 196 | "email": "admin@alerta.io", 197 | "exp": 1543264150, 198 | "iat": 1542054550, 199 | "iss": "https://alerta-api.herokuapp.com/", 200 | "jti": "54bfe4fd-5ed3-4d43-abc5-09cea407af94", 201 | "name": "admin@alerta.io", 202 | "nbf": 1542054550, 203 | "preferred_username": "admin@alerta.io", 204 | "provider": "basic", 205 | "roles": [ 206 | "admin" 207 | ], 208 | "scope": "admin read write", 209 | "sub": "df97c902-9b66-42f1-97b8-efb37ad942d6" 210 | } 211 | """ 212 | 213 | m.get('/userinfo', text=whoami_response) 214 | result = self.runner.invoke(whoami_cmd, ['-u'], obj=self.obj) 215 | self.assertIn('preferred_username : admin@alerta.io', result.output) 216 | self.assertEqual(result.exit_code, 0) 217 | --------------------------------------------------------------------------------