From 41067209e73047f3b3802352144fecb21943b58c Mon Sep 17 00:00:00 2001 From: Antoine Drochon Date: Mon, 26 Jul 2021 14:37:11 -0700 Subject: [PATCH] Display error message when .edgerc file is not found Connector list in tail mode improvments Command return codes are documented in libeaa/error module Improved error tracking on connector health with performance details Additional coverage unit/system tests Bump version to 0.4.2 --- VERSION | 2 +- bin/akamai-eaa | 6 ++- bin/config.py | 13 +++-- cli.json | 2 +- libeaa/__init__.py | 0 libeaa/common.py | 4 +- libeaa/connector.py | 67 +++++++++++++++----------- libeaa/error.py | 41 ++++++++++++++++ test/__init__.py | 0 test/test.py | 115 ++++++++++++++++++++++++++++++-------------- 10 files changed, 177 insertions(+), 73 deletions(-) create mode 100644 libeaa/__init__.py create mode 100644 libeaa/error.py create mode 100644 test/__init__.py diff --git a/VERSION b/VERSION index 60a2d3e..f7abe27 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 \ No newline at end of file +0.4.2 \ No newline at end of file diff --git a/bin/akamai-eaa b/bin/akamai-eaa index c4bab23..b0548f1 100755 --- a/bin/akamai-eaa +++ b/bin/akamai-eaa @@ -27,6 +27,7 @@ import time import platform import logging import fnmatch +import signal import http.client as http_client from json import dumps @@ -149,6 +150,8 @@ if __name__ == "__main__": setup_logging() logging.debug("Python %s" % platform.python_version()) + signal.signal(signal.SIGTERM, cli.exit_gracefully) + try: if config.command == "version": print(__version__) @@ -194,7 +197,8 @@ if __name__ == "__main__": # the ArgumentParser won't have the attribute set json = hasattr(config, 'json') and config.json tail = hasattr(config, 'tail') and config.tail - c.list(perf, json, tail) + interval = hasattr(config, 'interval') and config.interval + c.list(perf, json, tail, interval, cli.stop_event) elif config.command in ("certificate", "cert"): c = CertificateAPI(config) if config.action is None or config.action == "list" or config.certificate_id is None: diff --git a/bin/config.py b/bin/config.py index 9a79cf7..3a0b54b 100644 --- a/bin/config.py +++ b/bin/config.py @@ -21,6 +21,8 @@ from configparser import ConfigParser +import _paths +from error import rc_error, cli_exit_with_error class EdgeGridConfig(): @@ -140,6 +142,7 @@ def __init__(self, config_values, configuration, flags=None): list_parser.add_argument('--perf', default=False, action="store_true", help='Show performance metrics') list_parser.add_argument('--json', '-j', default=False, action="store_true", help='View as JSON') list_parser.add_argument('--tail', '-f', default=False, action="store_true", help='Keep watching, do not exit until Control+C/SIGTERM') + list_parser.add_argument('--interval', '-i', default=300, type=float, help='Interval between update (works with --tail only)') # subparsers.required = False swap_parser = subsub.add_parser("swap", help="Swap connector with another one") swap_parser.add_argument(dest="new_connector_id", help='New connector ID') @@ -179,7 +182,7 @@ def __init__(self, config_values, configuration, flags=None): try: args = parser.parse_args() except Exception: - sys.exit(1) + cli_exit_with_error(rc_error.GENERAL_ERROR) arguments = vars(args) @@ -194,9 +197,8 @@ def __init__(self, config_values, configuration, flags=None): if not config.has_section(configuration): err_msg = "ERROR: No section named %s was found in your %s file\n" % \ (configuration, arguments["edgerc"]) - err_msg += "ERROR: Please generate credentials for the script functionality\n" - err_msg += "ERROR: and run 'python gen_edgerc.py %s' to generate the credential file\n" % configuration - sys.exit(err_msg) + cli_exit_with_error(rc_error.EDGERC_SECTION_NOT_FOUND, err_msg) + for key, value in config.items(configuration): # ConfigParser lowercases magically if key not in arguments or arguments[key] is None: @@ -206,6 +208,9 @@ def __init__(self, config_values, configuration, flags=None): print("Run python gen_edgerc.py to get your credentials file set up " "once you've provisioned credentials in Akamai Control Center.") return None + else: + err_msg = f"ERROR: EdgeRc configuration {arguments['edgerc']} not found.\n" + cli_exit_with_error(rc_error.EDGERC_MISSING.value, err_msg) for option in arguments: setattr(self, option, arguments[option]) diff --git a/cli.json b/cli.json index 67f14c1..da4f6bc 100755 --- a/cli.json +++ b/cli.json @@ -5,7 +5,7 @@ "commands": [ { "name": "eaa", - "version": "0.4.1", + "version": "0.4.2", "description": "Akamai CLI for Enterprise Application Access (EAA)" } ] diff --git a/libeaa/__init__.py b/libeaa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libeaa/common.py b/libeaa/common.py index 079fed2..8ac8b9d 100644 --- a/libeaa/common.py +++ b/libeaa/common.py @@ -35,7 +35,7 @@ config = EdgeGridConfig({'verbose': False}, 'default') #: cli-eaa version -__version__ = '0.4.1' +__version__ = '0.4.2' #: HTTP Request Timeout in seconds HTTP_REQ_TIMEOUT = 300 @@ -227,7 +227,7 @@ def get(self, url_path, params=None): Send a GET reques to the API. """ url = urljoin(self._baseurl, url_path) - response = self._session.get(url, params=self.build_params(params), timeout=HTTP_REQ_TIMEOUT) + response = self._session.get(url, params=self.build_params(params), timeout=HTTP_REQ_TIMEOUT) logging.info("BaseAPI: GET response is HTTP %s" % response.status_code) if response.status_code != requests.status_codes.codes.ok: logging.info("BaseAPI: GET response body: %s" % response.text) diff --git a/libeaa/connector.py b/libeaa/connector.py index e8aafdb..8dafed0 100644 --- a/libeaa/connector.py +++ b/libeaa/connector.py @@ -62,18 +62,21 @@ def perf_system(self, connector_id): Returns: [tuple]: (connector_id, dictionnary with metric name as key) """ - systemres_api_url = 'mgmt-pop/agents/{agentid}/system_resource/metrics'.format(agentid=connector_id) - perf_data_resp = self.get(systemres_api_url, params={'period': '1h'}) - perf_data = perf_data_resp.json() - perf_latest = { - 'timestamp': None, - 'mem_pct': None, 'disk_pct': None, 'cpu_pct': None, - 'network_traffic_mbps': None, - 'dialout_total': None, 'dialout_idle': None, 'active_dialout_count': None - } - if len(perf_data.get('data', [])) >= 1: - perf_latest = perf_data.get('data', [])[-1] - return (connector_id, perf_latest) + try: # This method is executed as separate thread, we need the able to troubleshoot + systemres_api_url = 'mgmt-pop/agents/{agentid}/system_resource/metrics'.format(agentid=connector_id) + perf_data_resp = self.get(systemres_api_url, params={'period': '1h'}) + perf_data = perf_data_resp.json() + perf_latest = { + 'timestamp': None, + 'mem_pct': None, 'disk_pct': None, 'cpu_pct': None, + 'network_traffic_mbps': None, + 'dialout_total': None, 'dialout_idle': None, 'active_dialout_count': None + } + if len(perf_data.get('data', [])) >= 1: + perf_latest = perf_data.get('data', [])[-1] + return (connector_id, perf_latest) + except: + logging.exception("Error during fetching connector performance health.") def perf_apps(self, connector_id): """ @@ -170,29 +173,37 @@ def list_once(self, perf=False, json_fmt=False): if not json_fmt: cli.footer("Total %s connector(s)" % total_con) - def list(self, perf, json_fmt, follow=False, stopEvent=None): + def list(self, perf, json_fmt, follow=False, interval=300, stop_event=None): """ List the connector and their attributes and status The default output is CSV Args: - perf (bool): Add performance data (cpu, mem, disk, dialout) - json_fmt (bool): Output as JSON instead of CSV - follow (bool): Never stop until Control+C or SIGTERM is received + perf (bool): Add performance data (cpu, mem, disk, dialout) + json_fmt (bool): Output as JSON instead of CSV + follow (bool): Never stop until Control+C or SIGTERM is received + interval (float): Interval in seconds between pulling the API, default is 5 minutes (300s) + stop_event (Event): Main program stop event allowing the function + to stop at the earliest possible """ - interval_sec = 5 * 60 # Interval in seconds between pulling the API, default is 5 minutes - while True or (stopEvent and not stopEvent.is_set()): - start = time.time() - self.list_once(perf, json_fmt) - if follow: - sleep_time = interval_sec - (time.time() - start) - if sleep_time > 0: - time.sleep(sleep_time) + while True or (stop_event and not stop_event.is_set()): + try: + start = time.time() + self.list_once(perf, json_fmt) + if follow: + sleep_time = interval - (time.time() - start) + if sleep_time > 0: + time.sleep(sleep_time) + else: + logging.error(f"The EAA Connector API is slow to respond (could be also a proxy in the middle), holding for {interval} sec.") + time.sleep(interval) else: - logging.error(f"The EAA Connector API is slow to respond (could be also a proxy in the middle), holding for {interval_sec} sec.") - time.sleep(interval_sec) - else: - break + break + except Exception as e: + if follow: + logging.error(f"General exception {e}, since we are in follow mode (--tail), we keep going.") + else: + raise def findappbyconnector(self, connector_moniker): """ diff --git a/libeaa/error.py b/libeaa/error.py new file mode 100644 index 0000000..d8f43c9 --- /dev/null +++ b/libeaa/error.py @@ -0,0 +1,41 @@ +# Copyright 2021 Akamai Technologies, Inc. All Rights Reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from enum import Enum +from unittest.signals import installHandler + +class rc_error(Enum): + """ + Command line return codes from 0 (all good) to 255 + """ + OK = 0 + GENERAL_ERROR = 2 + EDGERC_MISSING = 30 + EDGERC_SECTION_NOT_FOUND = 31 + ERROR_NOT_SPECIFIED = 255 + + +def cli_exit_with_error(error_code, message=None): + """ + Exit the command line with error_code (integer or rc_error) with an optional message. + """ + if message: + sys.stderr.write(message) + if isinstance(error_code, rc_error): + sys.exit(error_code.value) + elif isinstance(error_code, int): + sys.exit(error_code) + else: + sys.exit(rc_error.ERROR_NOT_SPECIFIED.value) \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test.py b/test/test.py index 32bd725..e261ad5 100644 --- a/test/test.py +++ b/test/test.py @@ -15,30 +15,38 @@ """ This module replaces the old test.bash script. -## Prep your environment for nose2 - -```bash -cd [cli-eaa-directory] -. ./venv/bin/activate -pip install nose nose2-html-report -``` - -## Test with nose2 -```bash -cd test -nose2 --html-report -v -open report.html -``` - -## Test with pytest-html -See https://pytest-html.readthedocs.io/en/latest/ -``` -cd test -URL_TEST_TRAFFIC=https://login:password@myclassicapp.go.akamai-access.com pytest --html=report.html --self-contained-html test.py -``` +Test with nose2 +--------------- + +Prep your environment for nose2 + +.. code-block:: bash + cd [cli-eaa-directory] + . ./venv/bin/activate # or any other location/preference + pip install nose nose2-html-report + +Run the test + +.. code-block:: bash + cd test + nose2 --html-report -v + open report.html + +Test with pytest-html +--------------------- + +See also https://pytest-html.readthedocs.io/en/latest/ + +.. code-block:: bash + cd test + # Specify the test app URL to generate traffic against + URL_TEST_TRAFFIC=https://login:password@myclassicapp.go.akamai-access.com pytest --html=report.html --self-contained-html test.py + # test a specific test fixture + pytest --html=report.html --self-contained-html -k test_connector_health_tail test.py """ +# Python builtin modules import unittest import subprocess import shlex @@ -46,9 +54,15 @@ from pathlib import Path import collections import os -import requests import json import re +import signal + +# CLI EAA +from libeaa.error import rc_error + +# 3rd party modules +import requests # Global variables @@ -57,6 +71,7 @@ def pytest_html_report_title(report): report.title = "Akamai cli-eaa" + class CliEAATest(unittest.TestCase): testdir = None maindir = None @@ -81,13 +96,16 @@ def cli_command(self, *args): command.append(edgerc) # Then CLI-EAA arguments command.extend(*args) - print("\nSHELL COMMAND: ", shlex.join(command)) + CliEAATest.cli_print("\nSHELL COMMAND: ", shlex.join(command)) return command def cli_run(self, *args): cmd = subprocess.Popen(self.cli_command(str(a) for a in args), stdout=subprocess.PIPE, stderr=subprocess.PIPE) return cmd + def cli_print(*s): + print("cli-eaa>", *s) + def url_safe_print(url): """ Securely print the output of a URL that may have authentication credentials. @@ -109,7 +127,7 @@ def duplicate_count(filename): counts = collections.Counter(l.strip() for l in infile) for line, count in counts.most_common(): if count > 1: - print(f"DUPLICATE[{count}] {line}") + CliEAATest.cli_print(f"DUPLICATE[{count}] {line}") total_count += 1 return total_count @@ -138,16 +156,16 @@ def config_testapp_url(cls): delay = 15 url = os.getenv('URL_TEST_TRAFFIC') if url: - print(f"Test fingerprint: {id(cls):x}") - print("Generating some traffic against base URL_TEST_TRAFFIC={urle}...".format(urle=CliEAATest.url_safe_print(url))) + CliEAATest.cli_print(f"Test fingerprint: {id(cls):x}") + CliEAATest.cli_print("Generating some traffic against base URL_TEST_TRAFFIC={urle}...".format(urle=CliEAATest.url_safe_print(url))) for i in range(0, 10): resp = requests.get(url, params={'__unittest_fp': f"{id(cls):x}", '__unittest_seq': i}) - print(f"{i}:", CliEAATest.url_safe_print(resp.url), resp) + CliEAATest.cli_print(f"{i}:", CliEAATest.url_safe_print(resp.url), resp) # time.sleep(0.3) - print(f"Now waiting {delay}s to get the log collected") + CliEAATest.cli_print(f"Now waiting {delay}s to get the log collected") time.sleep(delay) else: - print("WARNING: no environment variable URL_TEST_TRAFFIC defined, we assume the traffic is generated separately") + CliEAATest.cli_print("WARNING: no environment variable URL_TEST_TRAFFIC defined, we assume the traffic is generated separately") def test_useraccess_log_raw(self): """ @@ -157,7 +175,7 @@ def test_useraccess_log_raw(self): stdout, stderr = cmd.communicate(timeout=60) events = stdout.decode(encoding) event_count = len(events.splitlines()) - self.assertGreater(event_count, 0, "We expect at least one user access event") + self.assertGreater(event_count, 0, "We expect at least one user access event, set URL_TEST_TRAFFIC env") self.assertEqual(cmd.returncode, 0, 'return code must be 0') def test_useraccess_log_raw_v2(self): @@ -168,7 +186,7 @@ def test_useraccess_log_raw_v2(self): stdout, stderr = cmd.communicate(timeout=60) events = stdout.decode(encoding) event_count = len(events.splitlines()) - self.assertGreater(event_count, 0, "We expect at least one user access event") + self.assertGreater(event_count, 0, "We expect at least one user access event, set URL_TEST_TRAFFIC env") self.assertEqual(cmd.returncode, 0, 'return code must be 0') def test_admin_log_raw(self): @@ -196,7 +214,7 @@ def test_useraccess_log_json(self): cmd = self.cli_run("log", "access", "--start", self.after, "--end", self.before, "--json") stdout, stderr = cmd.communicate(timeout=60) events = stdout.decode(encoding) - print(events) + CliEAATest.cli_print(events) event_count = len(events.splitlines()) self.assertGreater(event_count, 0, "We expect at least one user access event") self.assertEqual(cmd.returncode, 0, 'return code must be 0') @@ -212,7 +230,7 @@ def test_useraccess_log_json_v2(self): lines = scanned_events.splitlines() for l in lines: event = json.loads(l) - print(json.dumps(event, indent=2)) + CliEAATest.cli_print(json.dumps(event, indent=2)) event_count = len(lines) self.assertGreater(event_count, 0, "We expect at least one user access event") @@ -281,6 +299,22 @@ def test_connector_health_json(self): con_count = len(output.splitlines()) self.assert_list_connectors(con_count, cmd) + def test_connector_health_tail(self): + """ + Run connector command to fetch full health statuses in follow mode + We use the RAW format for convenience (easier to read in the output) + """ + cmd = self.cli_run('-d', '-v', 'c', 'list', '--perf', '--tail', '-i', '5') + time.sleep(60) # Long enough to collect some data + cmd.send_signal(signal.SIGINT) + stdout, stderr = cmd.communicate(timeout=50.0) + CliEAATest.cli_print("rc: ", cmd.returncode) + for l in stdout.splitlines(): + CliEAATest.cli_print("stdout>", l) + for l in stderr.splitlines(): + CliEAATest.cli_print("stderr>", l) + self.assertGreater(len(stdout), 0, "No connector health output") + class TestIdentity(CliEAATest): @@ -303,11 +337,20 @@ def test_no_edgerc(self): """ Call CLI with a bogus edgerc file, help should be displayed. """ - cmd = self.cli_run('-e', 'file_not_exist') + cmd = self.cli_run('-e', 'file_not_exist', 'info') + stdout, stderr = cmd.communicate() + output = stdout.decode(encoding) + self.assertEqual(cmd.returncode, rc_error.EDGERC_MISSING.value, f'return code must be {rc_error.EDGERC_MISSING.value}') + + def test_missing_section(self): + """ + Call CLI with a bogus edgerc file, help should be displayed. + """ + cmd = self.cli_run('--section', 'section_does_not_exist', 'info') stdout, stderr = cmd.communicate() output = stdout.decode(encoding) - self.assertIn("usage: akamai eaa", output) - # self.assertEqual(cmd.returncode, 0, 'return code must be 0') + self.assertEqual(cmd.returncode, rc_error.EDGERC_SECTION_NOT_FOUND.value, f'return code must be {rc_error.EDGERC_MISSING.value}') + def test_cli_version(self): """