From f91af26344fbfb2721974d0053d649c2a3467d00 Mon Sep 17 00:00:00 2001 From: marcwitasee Date: Wed, 24 May 2023 12:23:34 -0500 Subject: [PATCH 1/4] add ndt7 measurement --- src/netrics/measurement/ndt7.py | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/netrics/measurement/ndt7.py b/src/netrics/measurement/ndt7.py index d83eaf1..6940ab7 100644 --- a/src/netrics/measurement/ndt7.py +++ b/src/netrics/measurement/ndt7.py @@ -1,4 +1,142 @@ +#!/usr/bin/env python3 +"""Measure Internet bandwidth, *etc*., via Measurement Lab's NDT7 Client CLI.""" import subprocess as sp +import json + +from schema import Optional, Or +from netrics import task +from .common import require_net + +PARAMS = task.schema.extend('ndt7', { + # exec: speedtest executable name or path + Optional('exec', default='ndt7-client'): task.schema.Command( + error='exec: must be an executable on PATH or file system absolute path to be executable' + ), + + # timeout: seconds after which test is canceled + # (0, None, False, etc. to disable timeout) + Optional('timeout', default=60): Or(task.schema.GTZero(), + task.schema.falsey, + error='timeout: seconds greater than zero or ' + 'falsey to disable'), +}) + +@task.param.require(PARAMS) +@require_net +def main(params): + + try: + proc = sp.run( + ( + params.exec, + '-format', 'json', + ), + capture_output=True, + text=True + ) + + except sp.TimeoutExpired as exc: + task.log.critical( + cmd=exc.cmd, + elapsed=exc.timeout, + stdout=exc.stdout, + stderr=exc.stderr, + status='timeout', + ) + return task.status.timeout + + parsed = parse_output(proc.stdout) + + if not parsed: + task.log.critical( + status=f'Error ({proc.returncode})', + stdout=proc.stdout, + stderr=proc.stderr, + msg="no results", + ) + return task.status.no_host + + if proc.stderr: + task.log.error( + status=f'Error ({proc.returncode})', + stdout=proc.stdout, + stderr=proc.stderr, + msg="results despite errors", + ) + + task.log.info( + download=parsed['download'], + upload=parsed['upload'], + bytes_consumed=parsed['meta'].get('total_bytes_consumed'), + downloaduuid=parsed['downloaduuid'], + ) + + # flatten results + data = {key: value for (key, value) in parsed.items() if key != 'meta'} + + if params.result.flat: + results = {f'speedtest_ndt7_{feature}': value + for (feature, value) in data.items()} + + else: + results = {'speedtest_ndt7': data} + + # extend results with non-measurement data + if 'total_bytes_consumed' in parsed['meta']: + extended = {'test_bytes_consumed': parsed['meta']['total_bytes_consumed']} + else: + extended = None + + # write results + task.result.write(results, + label=params.result.label, + annotate=params.result.annotate, + extend=extended) + + return task.status.success + + +def parse_output(output): + + if not output: + return None + + try: + for obj in output.split("\n")[:-1]: + + response = json.loads(obj) + key = response.get("Key", None) + value = response.get("Value", None) + + if key == "measurement": + origin = value['Origin'] + test = value['Test'] + if origin == 'client': + num_bytes = value["AppInfo"]["NumBytes"] + if test == "download": + dl_bytes = num_bytes + else: + ul_bytes = num_bytes + + if (not key) & (not value): + result = { + 'download': response["Download"]["Value"], + 'upload': response["Upload"]["Value"], + 'downloadretrans': response["DownloadRetrans"]["Value"], + 'minrtt': response["MinRTT"]["Value"], + 'server': response["ServerFQDN"], + 'server_ip': response["ServerIP"], + 'downloaduuid': response["DownloadUUID"], + 'meta': {} + } + + except (KeyError, ValueError, TypeError) as exc: + print("output parsing error") + return None + else: + result["meta"]["total_bytes_consumed"] = dl_bytes + ul_bytes + return result + if __name__ == '__main__': main() From 67886988bba84662b50554940131db3a146ba336 Mon Sep 17 00:00:00 2001 From: marcwitasee Date: Thu, 25 May 2023 13:11:18 -0500 Subject: [PATCH 2/4] update built-in measurements table for speed-ndt7 & speed-ookla Co-Authored-By: Jesse London --- readme.adoc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/readme.adoc b/readme.adoc index 2a9204f..a9251b6 100644 --- a/readme.adoc +++ b/readme.adoc @@ -505,15 +505,15 @@ Any task configuration may specify the `command` setting with the value `ping` t |... |... -|`ndt7` -|`netrics-ndt7` -|... +|`speed-ndt7` +|`netrics-speed-ndt7` |... +|Run a network diagnostic test using Measurement Lab's ndt7-client. -|`ookla` -|`netrics-ookla` -|... +|`speed-ookla` +|`netrics-speed-ookla` |... +|Run a network diagnostic test using Ookla's Speedtest. |`ping` |`netrics-ping` From 634d3e62079938b4e4f5b6d71fcdfce1b44410e6 Mon Sep 17 00:00:00 2001 From: Jesse London Date: Thu, 8 Jun 2023 15:17:43 -0500 Subject: [PATCH 3/4] speed-ndt7: completed & polished measurement using NDT7 client * implemented timeout * fixed output parsing * etc. see #23 --- src/netrics/measurement/ndt7.py | 153 +++++++++++++++++++++----------- 1 file changed, 101 insertions(+), 52 deletions(-) diff --git a/src/netrics/measurement/ndt7.py b/src/netrics/measurement/ndt7.py index 6940ab7..ddd3303 100644 --- a/src/netrics/measurement/ndt7.py +++ b/src/netrics/measurement/ndt7.py @@ -1,41 +1,66 @@ -#!/usr/bin/env python3 -"""Measure Internet bandwidth, *etc*., via Measurement Lab's NDT7 Client CLI.""" -import subprocess as sp +"""Measure Internet bandwidth, *etc*., via Measurement Lab's NDT7 client.""" import json +import subprocess from schema import Optional, Or + from netrics import task + from .common import require_net + PARAMS = task.schema.extend('ndt7', { - # exec: speedtest executable name or path + # exec: ndt7-client executable name or path Optional('exec', default='ndt7-client'): task.schema.Command( - error='exec: must be an executable on PATH or file system absolute path to be executable' + error='exec: must be an executable on PATH or file system absolute path to executable' ), # timeout: seconds after which test is canceled # (0, None, False, etc. to disable timeout) - Optional('timeout', default=60): Or(task.schema.GTZero(), + Optional('timeout', default=45): Or(task.schema.GTZero(), task.schema.falsey, error='timeout: seconds greater than zero or ' 'falsey to disable'), }) + @task.param.require(PARAMS) @require_net def main(params): + """Measure Internet bandwidth, *etc*., via M-Lab's NDT7 client. + + The local network, and then Internet hosts (as configured in global + defaults), are queried first, to ensure network operation and + internet accessibility. (See: `require_net`.) + The ndt7-client binary is then executed. + + This binary is presumed to be accessible via PATH at `ndt7-client`. + This PATH look-up name is configurable, and may be replaced with the + absolute file system path, instead (`exec`). + + Should the speedtest not return within `timeout` seconds, an error + is returned. (This may be disabled by setting a "falsey" timeout + value.) + + In addition to NDT metrics such as download bandwidth + (`download`) and upload bandwidth (`upload`), measurement results + are written to include the key `test_bytes_consumed`. This item is + *not* included under the test's `label`, (regardless of `result` + configuration). + + """ try: - proc = sp.run( + proc = subprocess.run( ( params.exec, '-format', 'json', ), + timeout=(params.timeout or None), capture_output=True, text=True ) - - except sp.TimeoutExpired as exc: + except subprocess.TimeoutExpired as exc: task.log.critical( cmd=exc.cmd, elapsed=exc.timeout, @@ -67,8 +92,8 @@ def main(params): task.log.info( download=parsed['download'], upload=parsed['upload'], - bytes_consumed=parsed['meta'].get('total_bytes_consumed'), - downloaduuid=parsed['downloaduuid'], + bytes_consumed=parsed['meta']['total_bytes_consumed'], + uuid_download=parsed['meta']['downloaduuid'], ) # flatten results @@ -82,7 +107,7 @@ def main(params): results = {'speedtest_ndt7': data} # extend results with non-measurement data - if 'total_bytes_consumed' in parsed['meta']: + if parsed['meta']['total_bytes_consumed']: extended = {'test_bytes_consumed': parsed['meta']['total_bytes_consumed']} else: extended = None @@ -97,46 +122,70 @@ def main(params): def parse_output(output): + """Parse output from M-Lab NDT7 client. - if not output: - return None - - try: - for obj in output.split("\n")[:-1]: - - response = json.loads(obj) - key = response.get("Key", None) - value = response.get("Value", None) - - if key == "measurement": - origin = value['Origin'] - test = value['Test'] - if origin == 'client': - num_bytes = value["AppInfo"]["NumBytes"] - if test == "download": - dl_bytes = num_bytes - else: - ul_bytes = num_bytes - - if (not key) & (not value): - result = { - 'download': response["Download"]["Value"], - 'upload': response["Upload"]["Value"], - 'downloadretrans': response["DownloadRetrans"]["Value"], - 'minrtt': response["MinRTT"]["Value"], - 'server': response["ServerFQDN"], - 'server_ip': response["ServerIP"], - 'downloaduuid': response["DownloadUUID"], - 'meta': {} - } - - except (KeyError, ValueError, TypeError) as exc: - print("output parsing error") - return None - else: - result["meta"]["total_bytes_consumed"] = dl_bytes + ul_bytes - return result + Note: Should output not conform to expectations, `None` may be + returned. + """ + try: + # + # output consists of one or more lines of JSON objects + # + # this should entail arbitrary status lines (without -quiet flag) + # followed by a single summary line + # + (*statuses, summary) = (json.loads(line) for line in output.splitlines()) + + # + # bytes consumed by the tests may only be retrieved from the status + # lines under the measurement key + # + # we may retrieve the total bytes consumed by each test via the + # *last* status line + # + (bytes_dl, bytes_ul) = ( + # + # retrieve the *last* matching element by iterating statuses in *reverse* + # + # (if no matching element evaluate 0) + # + next( + ( + status['Value']['AppInfo']['NumBytes'] + for status in reversed(statuses) + if ( + status['Key'] == 'measurement' and + status['Value']['Origin'] == 'client' and + status['Value']['Test'] == test_name + ) + ), + 0, + ) + for test_name in ('download', 'upload') + ) -if __name__ == '__main__': - main() + # + # all other results are presented by the summary + # + return { + 'download': summary['Download']['Throughput']['Value'], + 'upload': summary['Upload']['Throughput']['Value'], + + 'downloadretrans': summary['Download']['Retransmission']['Value'], + 'downloadlatency': summary['Download']['Latency']['Value'], + + 'server': summary['ServerFQDN'], + 'server_ip': summary['ServerIP'], + + 'meta': { + 'downloaduuid': summary['Download']['UUID'], + 'total_bytes_consumed': bytes_dl + bytes_ul, + } + } + except (KeyError, TypeError, ValueError) as exc: + task.log.error( + error=str(exc), + msg="output parsing error", + ) + return None From 0a46f0b30c85a633cd6ba3b10660a734cdfc0e9f Mon Sep 17 00:00:00 2001 From: Jesse London Date: Thu, 8 Jun 2023 17:07:52 -0500 Subject: [PATCH 4/4] speed-ndt7: added measurement to default configuration The NDT7 test is made the model for speed test configuration (by the alphabet and as it requires no additional paramters). speed-ookla is intended to have the same configuration, with additional parameters -- and so YAML anchor/alias is used. Fate is upgraded to receive a bug-fix fully supporting this YAML feature. See #23 --- pyproject.toml | 2 +- src/netrics/conf/include/measurements.yaml | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9bd3073..2ae90b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ packages = [{include = "netrics", from = "src"}] [tool.poetry.dependencies] python = "^3.8" -fate-scheduler = "0.1.0-rc.13" +fate-scheduler = "0.1.0-rc.14" netifaces = "^0.11.0" [tool.poetry.dev-dependencies] diff --git a/src/netrics/conf/include/measurements.yaml b/src/netrics/conf/include/measurements.yaml index 883d0be..29dafaf 100644 --- a/src/netrics/conf/include/measurements.yaml +++ b/src/netrics/conf/include/measurements.yaml @@ -69,7 +69,7 @@ ping: 195.89.146.193: Stockholm 190.98.158.1: Sao_Paulo -speed-ookla: +speed-ndt7: &speed-conf schedule: # # Hourly. @@ -105,6 +105,12 @@ speed-ookla: # tenancy: 1 +speed-ookla: + # + # See: speed-ndt7 + # + << : *speed-conf + param: accept_license: true timeout: 80