From d4c309dc694c46e70b01c0ebbba991ca5b7d3c5e Mon Sep 17 00:00:00 2001 From: Maciej Obuchowski Date: Tue, 9 Jun 2026 09:17:29 +0200 Subject: [PATCH 1/2] postgres data-observability - use cron to precisely schedule queries (#23529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * postgres data-observability - use cron to precisely schedule queries Signed-off-by: mobuchowski * review Signed-off-by: mobuchowski * fix: apply ddev formatter Signed-off-by: mobuchowski * Skip invalid queries at construction time Signed-off-by: Maciej Obuchowski * remove lookback as configurable param Signed-off-by: mobuchowski * Replace croniter with datadog_checks_base CronScheduler utility Drop the third-party croniter==6.2.2 dependency and rewrite the cron scheduling in PostgresDataObservability on top of the CronExpression / CronScheduler utilities added to datadog_checks_base in #23741. Changes: - _filter_valid_queries: build a CronScheduler per monitor_id inside a try/except(ValueError, TypeError) instead of calling croniter.is_valid. Scheduler captures startup_lookback at construction time. - _get_due_queries: collapse the entire cron branch to a single due_ticks(now) call; state registration, lookback recovery, tick detection, and advancement are all handled by CronScheduler. - run_job: remove the post-fire croniter re-advance; due_ticks() already advanced the scheduler at poll time. - Tests: replace _next_run[mid] accesses with _schedulers[mid].next_tick; update boundary test to reflect CronScheduler's inclusive (<=) lookback semantics vs the old strict (<) comparison. - Remove croniter from agent_requirements.in, postgres/pyproject.toml, and LICENSE-3rdparty.csv; bump datadog-checks-base pin to >=37.39.0 (first release shipping cron.py). Signed-off-by: mobuchowski Co-Authored-By: Claude Opus 4.8 Signed-off-by: mobuchowski * Address code review: type hints, explicit state returns, lateness comment, error guard - _filter_valid_queries: add Iterable[Query] parameter type; return (tuple[Query,...], dict[int,CronScheduler]) so callers own the assignment — removes the hidden self._schedulers side-effect. __init__ now assigns both fields explicitly. - _get_due_queries: remove setdefault seed for _last_execution; use .get() and treat None as first-sight. Scheduling-state mutations (seed and advance) are now consolidated in run_job. - run_job: add comment distinguishing lateness (scheduling delay) from execution time; wrap emit_failures count in a nested try/except so a concurrent _shutdown() cannot turn the error handler into a job crash. - Tests: merge _make_cron_lookback_check into _make_cron_check via optional window_seconds/monkeypatch params; collapse three identical lookback-window tests into one parametrized test_cron_startup_lookback_window_behavior. Signed-off-by: mobuchowski Co-Authored-By: Claude Opus 4.8 Signed-off-by: mobuchowski * fmt Signed-off-by: mobuchowski * code review fixes Signed-off-by: mobuchowski * postgres DO: fix cron boundary miss and improve schedule error message - due_ticks now probes now+0.001 so a first poll landing exactly on a cron tick boundary fires instead of skipping the cycle - invalid cron schedule warning now includes the exception message and the monitor_id to help customers identify and fix the bad config - regression test added for the exact-boundary case Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: mobuchowski * revert cron.py boundary fix (moved to postgres data_observability) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: mobuchowski * fix cron exact-boundary detection without inflating lookback window The previous +0.001 epsilon was passed directly to due_ticks(), which caused CronScheduler to compare (now+0.001) - prev <= lookback, breaking the inclusive-boundary test where now - prev == lookback exactly. Instead, call due_ticks(now) normally and only on the first poll (before next_tick is cached) probe previous_tick(before=now+0.001) to detect whether now itself is a tick. This isolates the epsilon to the boundary detection and leaves the lookback window comparison untouched. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: mobuchowski * simplify cron boundary fix; update over-precise test Use due_ticks(now + 0.001) — the simpler approach. The previous complex wrapper was added to avoid breaking test_cron_startup_lookback_boundary_inclusive, which was asserting now - prev == window exactly. That exact-equality check belongs in test_cron.py (CronScheduler unit tests), not here. Updated the test to use a clock 55s after the tick (clearly inside the 60s window), keeping the meaningful assertion that recovery fires inside the window and does not fire outside it. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: mobuchowski --------- Signed-off-by: mobuchowski Signed-off-by: Maciej Obuchowski Co-authored-by: Claude Opus 4.8 --- postgres/assets/configuration/spec.yaml | 11 +- postgres/changelog.d/23529.added | 1 + .../postgres/config_models/instance.py | 9 +- .../postgres/data_observability.py | 113 +++- postgres/tests/test_config.py | 96 +++ postgres/tests/test_data_observability.py | 547 +++++++++++++++++- 6 files changed, 731 insertions(+), 46 deletions(-) create mode 100644 postgres/changelog.d/23529.added diff --git a/postgres/assets/configuration/spec.yaml b/postgres/assets/configuration/spec.yaml index 971c04b6c504c..8aaf461a2ecb1 100644 --- a/postgres/assets/configuration/spec.yaml +++ b/postgres/assets/configuration/spec.yaml @@ -643,7 +643,6 @@ files: - monitor_id - dbname - query - - interval_seconds - entity properties: - name: monitor_id @@ -655,7 +654,17 @@ files: - name: query type: string - name: interval_seconds + description: | + How often (in seconds) to run this query. Ignored when schedule is set + (see schedule for the precedence rule). type: integer + - name: schedule + description: | + A standard 5-field cron expression (minute hour dom month dow) specifying + when to run this query. When both schedule and interval_seconds are set, + schedule wins and interval_seconds is ignored. If neither is set, the + query is skipped at runtime with a warning. + type: string - name: entity type: object required: diff --git a/postgres/changelog.d/23529.added b/postgres/changelog.d/23529.added new file mode 100644 index 0000000000000..8335feaa1ee2d --- /dev/null +++ b/postgres/changelog.d/23529.added @@ -0,0 +1 @@ +Support cron `schedule` field for Data Observability queries. diff --git a/postgres/datadog_checks/postgres/config_models/instance.py b/postgres/datadog_checks/postgres/config_models/instance.py index 3713d482698b3..d4951b24847ab 100644 --- a/postgres/datadog_checks/postgres/config_models/instance.py +++ b/postgres/datadog_checks/postgres/config_models/instance.py @@ -172,9 +172,16 @@ class Query(BaseModel): custom_sql_select_fields: Optional[CustomSqlSelectFields] = None dbname: str entity: Entity - interval_seconds: int + interval_seconds: Optional[int] = Field( + None, + description='How often (in seconds) to run this query. Ignored when schedule is set\n(see schedule for the precedence rule).\n', + ) monitor_id: int query: str + schedule: Optional[str] = Field( + None, + description='A standard 5-field cron expression (minute hour dom month dow) specifying\nwhen to run this query. When both schedule and interval_seconds are set,\nschedule wins and interval_seconds is ignored. If neither is set, the\nquery is skipped at runtime with a warning.\n', + ) type: Optional[str] = None diff --git a/postgres/datadog_checks/postgres/data_observability.py b/postgres/datadog_checks/postgres/data_observability.py index 5dce7eabb5b01..06d0c8f2bd9dd 100644 --- a/postgres/datadog_checks/postgres/data_observability.py +++ b/postgres/datadog_checks/postgres/data_observability.py @@ -5,10 +5,13 @@ import json import time -from typing import TYPE_CHECKING, Any +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal import psycopg +from datadog_checks.base.utils.cron import CronScheduler from datadog_checks.base.utils.db.utils import DBMAsyncJob, default_json_event_encoding if TYPE_CHECKING: @@ -17,15 +20,29 @@ EVENT_TRACK_TYPE = 'do-query-results' -# Cap the number of rows fetched per query to prevent unbounded memory usage. MAX_RESULT_ROWS = 10_000 +# After an agent start, run cron-scheduled queries whose scheduled time of execution +# fell within this many seconds in the past. Recovers missed runs across short check +# restarts (deploys, crashes, Remote Configuration redeliveries). Set to 0 to skip +# catch-up. +CRON_STARTUP_LOOKBACK_SECONDS = 300 + +Mode = Literal["cron", "interval"] + + +@dataclass(frozen=True) +class DueQuery: + query: Query + scheduled_time: float + mode: Mode + class PostgresDataObservability(DBMAsyncJob): def __init__(self, check: PostgreSql, config: InstanceConfig): self._check = check self._config = config - self._last_execution: dict[int, float] = {} + self._last_execution: dict[int, float] = {} # interval mode: last fire timestamp collection_interval = config.data_observability.collection_interval or 10 super(PostgresDataObservability, self).__init__( check, @@ -37,6 +54,8 @@ def __init__(self, check: PostgreSql, config: InstanceConfig): expected_db_exceptions=(psycopg.errors.DatabaseError,), job_name="data-observability", ) + # Filter bad queries on check construction. + self._queries, self._schedulers = self._filter_valid_queries(self._do_config.queries or ()) def _shutdown(self): self._check = None @@ -45,14 +64,51 @@ def _shutdown(self): def _do_config(self): return self._config.data_observability - def _get_due_queries(self) -> list[Query]: - queries = self._do_config.queries or () - now = time.time() - due = [] + def _filter_valid_queries(self, queries: Iterable[Query]) -> tuple[tuple[Query, ...], dict[int, CronScheduler]]: + valid: list[Query] = [] + schedulers: dict[int, CronScheduler] = {} for q in queries: - last_run = self._last_execution.get(q.monitor_id, 0.0) - if now - last_run >= q.interval_seconds: - due.append(q) + if q.schedule: + try: + schedulers[q.monitor_id] = CronScheduler(q.schedule, startup_lookback=CRON_STARTUP_LOOKBACK_SECONDS) + except (ValueError, TypeError) as e: + self._log.warning( + "Skipping DO query monitor_id=%d: invalid cron schedule %r (%s). " + "Check the schedule of Data Observability monitor %d.", + q.monitor_id, + q.schedule, + e, + q.monitor_id, + ) + continue + elif not (q.interval_seconds and q.interval_seconds > 0): + self._log.warning( + "Skipping DO query monitor_id=%d: neither schedule nor positive interval_seconds set", + q.monitor_id, + ) + continue + valid.append(q) + return tuple(valid), schedulers + + def _get_due_queries(self) -> list[DueQuery]: + now = time.time() + due: list[DueQuery] = [] + for q in self._queries: + if q.schedule: + # +0.001 so a poll landing exactly on a tick boundary is treated + # as due (CronScheduler.previous_tick uses strict less-than). + ticks = self._schedulers[q.monitor_id].due_ticks(now + 0.001) + if ticks: + # Take the latest elapsed tick; earlier ones are already in the past + # and do not need separate execution. + due.append(DueQuery(q, ticks[-1], "cron")) + else: + last = self._last_execution.get(q.monitor_id) + if last is None or now - last >= q.interval_seconds: + # Seed: treat first sight as if the previous interval just completed, + # so the scheduled_time for DueQuery is now and lateness is 0. + scheduled = (last + q.interval_seconds) if last is not None else now + due.append(DueQuery(q, scheduled, "interval")) return due def _build_base_tags(self) -> list[str]: @@ -147,16 +203,20 @@ def run_job(self): base_tags = self._build_base_tags() - for q in due_queries: + for due in due_queries: + q = due.query tags = base_tags + [f'monitor_id:{q.monitor_id}'] + now_at_fire_start = time.time() with self._check.db_pool.get_connection(q.dbname) as conn: result = self._execute_single_query(conn, q) - # Update scheduling timestamp immediately after execution, before - # metric/event emission, so a serialization failure in the event - # path cannot cause infinite re-execution of the same query. - self._last_execution[q.monitor_id] = time.time() + # Advance scheduling state before emission so an emit-side error cannot + # leave the query stuck re-firing the same tick. + # For cron mode, due_ticks() already advanced the scheduler's internal state. + now_at_fire_end = time.time() + if due.mode == "interval": + self._last_execution[q.monitor_id] = now_at_fire_end try: self._check.gauge( @@ -174,6 +234,17 @@ def run_job(self): raw=True, ) + # Lateness measures scheduling delay only (time from tick to query start), + # not end-to-end result latency — query execution time is reported separately. + lateness = max(0.0, now_at_fire_start - due.scheduled_time) + self._check.gauge( + 'dd.postgres.data_observability.query_fire_lateness_seconds', + lateness, + tags=tags + [f'mode:{due.mode}'], + hostname=self._check.reported_hostname, + raw=True, + ) + payload = self._build_event_payload(q, result) raw_event = json.dumps(payload, default=default_json_event_encoding) self._log.debug( @@ -183,8 +254,18 @@ def run_job(self): result['row_count'], ) self._check.event_platform_event(raw_event, EVENT_TRACK_TYPE) - except Exception: + except Exception as e: self._log.exception( "Failed to emit metrics/event for monitor_id=%d", q.monitor_id, ) + try: + self._check.count( + 'dd.postgres.data_observability.emit_failures', + 1, + tags=tags + [f'exc_class:{type(e).__name__}'], + hostname=self._check.reported_hostname, + raw=True, + ) + except Exception: + pass diff --git a/postgres/tests/test_config.py b/postgres/tests/test_config.py index f0b81d4231cf3..026ec07eeeaba 100644 --- a/postgres/tests/test_config.py +++ b/postgres/tests/test_config.py @@ -610,3 +610,99 @@ def test_autodiscovery_exclude_none_does_not_error(mock_check): config, result = build_config(check=mock_check) assert result.valid assert config.dbname == 'main' + + +# --------------------------------------------------------------------------- +# Data Observability — schedule field config tests +# --------------------------------------------------------------------------- + + +def test_do_query_schedule_field_defaults_to_none(mock_check, minimal_instance): + """A DO query without schedule field has schedule=None by default.""" + minimal_instance['data_observability'] = { + 'enabled': True, + 'run_sync': True, + 'queries': [ + { + 'monitor_id': 1, + 'dbname': 'mydb', + 'query': 'SELECT 1', + 'interval_seconds': 60, + 'entity': { + 'platform': 'aws', + 'account': '123', + 'database': 'mydb', + 'schema': 'public', + 'table': 'foo', + }, + } + ], + } + mock_check.instance = minimal_instance + mock_check.init_config = {} + config, result = build_config(check=mock_check) + assert result.valid + query = config.data_observability.queries[0] + assert query.schedule is None + assert query.interval_seconds == 60 + + +def test_do_query_schedule_field_parsed(mock_check, minimal_instance): + """A DO query with schedule field is parsed correctly; interval_seconds may be absent.""" + minimal_instance['data_observability'] = { + 'enabled': True, + 'run_sync': True, + 'queries': [ + { + 'monitor_id': 2, + 'dbname': 'mydb', + 'query': 'SELECT 1', + 'schedule': '20 * * * *', + 'entity': { + 'platform': 'aws', + 'account': '123', + 'database': 'mydb', + 'schema': 'public', + 'table': 'bar', + }, + } + ], + } + mock_check.instance = minimal_instance + mock_check.init_config = {} + config, result = build_config(check=mock_check) + assert result.valid + query = config.data_observability.queries[0] + assert query.schedule == '20 * * * *' + assert query.interval_seconds is None + + +def test_do_query_both_schedule_and_interval_parsed(mock_check, minimal_instance): + """A DO query with both schedule and interval_seconds is accepted; both fields present.""" + minimal_instance['data_observability'] = { + 'enabled': True, + 'run_sync': True, + 'queries': [ + { + 'monitor_id': 3, + 'dbname': 'mydb', + 'query': 'SELECT 1', + 'schedule': '0 * * * *', + 'interval_seconds': 3600, + 'entity': { + 'platform': 'aws', + 'account': '123', + 'database': 'mydb', + 'schema': 'public', + 'table': 'baz', + }, + } + ], + } + mock_check.instance = minimal_instance + mock_check.init_config = {} + config, result = build_config(check=mock_check) + assert result.valid + query = config.data_observability.queries[0] + assert query.schedule == '0 * * * *' + assert query.interval_seconds == 3600 diff --git a/postgres/tests/test_data_observability.py b/postgres/tests/test_data_observability.py index 5947b3ccee3f4..ab13e139ed24c 100644 --- a/postgres/tests/test_data_observability.py +++ b/postgres/tests/test_data_observability.py @@ -3,6 +3,8 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations +import calendar +import datetime import json from contextlib import contextmanager from copy import deepcopy @@ -14,6 +16,9 @@ from datadog_checks.postgres import PostgreSql from datadog_checks.postgres.data_observability import EVENT_TRACK_TYPE +# Fixed epoch (2026-01-01 00:49:00 UTC) chosen to sit 1 minute before the ":50" cron tick used in tests. +_BASE_EPOCH = calendar.timegm(datetime.datetime(2026, 1, 1, 0, 49, 0).timetuple()) + pytestmark = pytest.mark.unit BASE_QUERY = { @@ -191,9 +196,9 @@ def test_per_query_interval_tracking(aggregator, pg_instance): check.data_observability.run_job() assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0 - # Reset _last_execution to force re-run + # Reset last_execution to force re-run aggregator.reset() - check.data_observability._last_execution = {1: 0.0} + check.data_observability._last_execution[1] = 0.0 check.data_observability.run_job() assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 1 @@ -348,30 +353,6 @@ def test_tags_include_monitor_id(aggregator, pg_instance): assert 'status:success' in exec_metrics[0].tags -def test_query_with_no_description(aggregator, pg_instance): - """Non-SELECT queries (cursor.description is None) are caught per-query and emit an error result.""" - mock_conn, mock_cursor = _make_mock_conn() - - def execute_side_effect(sql, *args, **kwargs): - mock_cursor.description = None - - mock_cursor.execute = MagicMock(side_effect=execute_side_effect) - - with patch.object(PostgreSql, 'event_platform_event') as mock_epe: - check = _create_check(pg_instance) - check.db_pool = _mock_db_pool(mock_conn) - check.data_observability.run_job() - - do_calls = _get_do_event_calls(mock_epe) - payload = json.loads(do_calls[0][0][0]) - - assert payload['status'] == 'error' - assert 'result set' in payload['error'] - metrics = aggregator.metrics('dd.postgres.data_observability.query_executions') - assert len(metrics) == 1 - assert 'status:error' in metrics[0].tags - - def test_collection_interval_none_uses_default(pg_instance): """collection_interval=None should not crash, uses default.""" instance = deepcopy(pg_instance) @@ -386,7 +367,7 @@ def test_collection_interval_none_uses_default(pg_instance): def test_failed_query_updates_last_execution(aggregator, pg_instance): - """A failed query still updates _last_execution so it's not retried until the next interval.""" + """A failed query still updates last_execution so it's not retried until the next interval.""" mock_conn, mock_cursor = _make_mock_conn() mock_cursor.execute.side_effect = psycopg.errors.ProgrammingError("syntax error") @@ -395,6 +376,7 @@ def test_failed_query_updates_last_execution(aggregator, pg_instance): check.data_observability.run_job() assert 1 in check.data_observability._last_execution + assert check.data_observability._last_execution[1] > 0 # Immediate re-run should skip the query (interval not elapsed) aggregator.reset() @@ -425,6 +407,31 @@ def test_error_event_payload(aggregator, pg_instance): assert 'duration_s' in payload +def test_query_with_no_description(aggregator, pg_instance): + """Non-SELECT queries (cursor.description is None) trigger the inner-raised ProgrammingError path + and surface 'result set' in the error payload.""" + mock_conn, mock_cursor = _make_mock_conn() + + def execute_side_effect(sql, *args, **kwargs): + mock_cursor.description = None + + mock_cursor.execute = MagicMock(side_effect=execute_side_effect) + + with patch.object(PostgreSql, 'event_platform_event') as mock_epe: + check = _create_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + check.data_observability.run_job() + + do_calls = _get_do_event_calls(mock_epe) + payload = json.loads(do_calls[0][0][0]) + + assert payload['status'] == 'error' + assert 'result set' in payload['error'] + metrics = aggregator.metrics('dd.postgres.data_observability.query_executions') + assert len(metrics) == 1 + assert 'status:error' in metrics[0].tags + + def test_fetchmany_called_with_max_rows(pg_instance): """fetchmany is called with MAX_RESULT_ROWS to cap memory usage.""" from datadog_checks.postgres.data_observability import MAX_RESULT_ROWS @@ -472,7 +479,7 @@ def test_per_query_dbname_in_event_payload(aggregator, pg_instance): def test_multi_query_different_dbnames(aggregator, pg_instance): - """Multiple queries with different dbnames each connect to the correct database.""" + """Each query in a multi-query batch routes its own dbname to get_connection and its payload.""" queries = [ {**deepcopy(BASE_QUERY), 'dbname': 'db_one'}, {**deepcopy(MULTI_QUERIES[1]), 'dbname': 'db_two'}, @@ -495,3 +502,487 @@ def test_multi_query_different_dbnames(aggregator, pg_instance): assert payload_1['db_name'] == 'db_one' assert payload_2['db_name'] == 'db_two' + + +# --------------------------------------------------------------------------- +# Cron schedule tests +# --------------------------------------------------------------------------- + +CRON_QUERY = { + 'monitor_id': 10, + 'dbname': 'test_db', + 'query': 'SELECT 1', + 'schedule': '50 * * * *', # every hour at :50 + 'type': 'freshness', + 'entity': { + 'platform': 'aws', + 'account': '123456', + 'database': 'test_db', + 'schema': 'public', + 'table': 'orders', + }, +} + + +def _make_cron_check(pg_instance, queries=None, *, window_seconds=None, monkeypatch=None): + if window_seconds is not None: + assert monkeypatch is not None, "monkeypatch is required when overriding window_seconds" + monkeypatch.setattr( + 'datadog_checks.postgres.data_observability.CRON_STARTUP_LOOKBACK_SECONDS', + window_seconds, + ) + return _create_check(pg_instance, queries=queries or [deepcopy(CRON_QUERY)]) + + +def test_schedule_query_does_not_fire_before_tick(pg_instance, monkeypatch): + """First-sight cron registers next_run without firing. + + Clock sits at 00:49:00, 59 minutes after the previous :50 tick — well + outside the 300s lookback window — so first-sight recovery does not + fire either. The next tick (00:50:00) is still 60s in the future. + """ + current_time = [float(_BASE_EPOCH)] # 00:49:00 + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + check.data_observability.run_job() + + mock_conn.cursor.assert_not_called() + + +def test_schedule_query_fires_at_cron_tick(pg_instance, aggregator, monkeypatch): + """A cron query fires after its first tick has passed.""" + current_time = [float(_BASE_EPOCH)] + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + + # First call: registers next_run (tick at 00:50:00) but does NOT fire. + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0 + + # Advance past 00:50:00 + current_time[0] = _BASE_EPOCH + 65 # 00:50:05 + aggregator.reset() + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 1 + + +def test_schedule_query_fires_when_first_poll_exactly_at_tick(pg_instance, aggregator, monkeypatch): + """First poll landing exactly on the cron tick must fire, not skip the cycle.""" + tick_time = float(_BASE_EPOCH + 60) # 00:50:00 — exactly on the :50 tick + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: tick_time) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 1 + + +def test_schedule_advances_after_run(pg_instance, monkeypatch): + """After a cron fire, the scheduler advances to the NEXT future tick (not the same tick).""" + current_time = [float(_BASE_EPOCH + 65)] # 00:50:05 — already past the tick + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + + # First run at 00:50:05: lookback recovery fires (5s within 300s window); scheduler + # caches next_tick = 01:50:00. + check.data_observability.run_job() + mid = CRON_QUERY['monitor_id'] + first_next_run = check.data_observability._schedulers[mid].next_tick + + current_time[0] = _BASE_EPOCH + 3665 # ~01:50:05 + check.data_observability.run_job() + + second_next_run = check.data_observability._schedulers[mid].next_tick + # After firing at 01:50:05, next_tick should advance to 02:50:00 + assert second_next_run > first_next_run + # Should be approximately 1 hour later + assert second_next_run - first_next_run >= 3600 - 10 + + +def test_schedule_takes_precedence_over_interval_seconds(pg_instance, aggregator, monkeypatch): + query = deepcopy(CRON_QUERY) + query['interval_seconds'] = 5 # Very short interval — should be ignored. + + current_time = [float(_BASE_EPOCH)] + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _create_check(pg_instance, queries=[query]) + check.db_pool = _mock_db_pool(mock_conn) + + # First call at 00:49:00: registers cron next_run (00:50:00), does NOT fire despite interval=5 passing. + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0 + + # Advance 10s (interval would have fired, but cron tick not reached yet). + current_time[0] = _BASE_EPOCH + 10 # 00:49:10 — past 5s interval, but not yet :50. + aggregator.reset() + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0 + + # Now advance past 00:50:00. + current_time[0] = _BASE_EPOCH + 65 # 00:50:05 + aggregator.reset() + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 1 + + +def test_invalid_cron_schedule_filtered_at_init(pg_instance, aggregator, caplog): + """A query with an unparseable cron string is dropped at construction; siblings still run.""" + import logging + + bad = {**deepcopy(CRON_QUERY), 'monitor_id': 20, 'schedule': 'not-a-cron'} + good = deepcopy(BASE_QUERY) # interval-based, monitor_id=1 + + mock_conn, _ = _make_mock_conn() + with caplog.at_level(logging.WARNING): + check = _create_check(pg_instance, queries=[bad, good]) + assert {q.monitor_id for q in check.data_observability._queries} == {1} + assert any('invalid cron schedule' in r.message and "'not-a-cron'" in r.message for r in caplog.records) + + check.db_pool = _mock_db_pool(mock_conn) + check.data_observability.run_job() + monitor_ids_run = { + int(t.split(':')[1]) + for m in aggregator.metrics('dd.postgres.data_observability.query_executions') + for t in m.tags + if t.startswith('monitor_id:') + } + assert monitor_ids_run == {1} + + +def test_query_without_schedule_or_positive_interval_filtered_at_init(pg_instance, caplog): + """A query with neither schedule nor a positive interval_seconds is dropped at construction.""" + import logging + + query = { + 'monitor_id': 30, + 'dbname': 'test_db', + 'query': 'SELECT 1', + 'type': 'freshness', + 'entity': { + 'platform': 'aws', + 'account': '123456', + 'database': 'test_db', + 'schema': 'public', + 'table': 'foo', + }, + } + with caplog.at_level(logging.WARNING): + check = _create_check(pg_instance, queries=[query]) + assert check.data_observability._queries == () + assert any('neither schedule nor positive interval_seconds' in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# Lateness metric tests +# --------------------------------------------------------------------------- + + +def test_lateness_metric_emitted_for_cron(pg_instance, aggregator, monkeypatch): + """Cron query fired late emits a positive lateness gauge with mode:cron tag.""" + # next_run will be at 00:50:00; fire at 00:52:00 -> 120s lateness. + fire_time = float(_BASE_EPOCH + 180) # 00:52:00 + current_time = [float(_BASE_EPOCH)] # 00:49:00 — before the tick + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + + # Step 1: First poll at 00:49:00 — prev tick is 23:50:00 (>>300s ago), no lookback recovery. + # Scheduler caches next_tick = 00:50:00. + check.data_observability.run_job() + mid = CRON_QUERY['monitor_id'] + scheduled_tick = check.data_observability._schedulers[mid].next_tick + + # Step 2: Advance to 00:52:00 — 2 minutes late + current_time[0] = fire_time + aggregator.reset() + check.data_observability.run_job() + + lateness_metrics = aggregator.metrics('dd.postgres.data_observability.query_fire_lateness_seconds') + assert len(lateness_metrics) == 1 + m = lateness_metrics[0] + assert m.value >= 0.0 + assert 'mode:cron' in m.tags + assert f'monitor_id:{mid}' in m.tags + # Lateness should be fire_time - scheduled_tick ≈ 120s (within 5s tolerance for real-time drift) + expected_lateness = fire_time - scheduled_tick + assert abs(m.value - expected_lateness) < 5.0 + + +def test_lateness_metric_emitted_for_interval(pg_instance, aggregator, monkeypatch): + """Interval query emits lateness gauge with mode:interval tag. + + First fire is lazy-seeded to scheduled = now so lateness is 0; second + fire after a delay exposes the configured interval as positive lateness. + """ + current_time = [1000.0] + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + query = deepcopy(BASE_QUERY) # monitor_id=1, interval_seconds=60 + + check = _create_check(pg_instance, queries=[query]) + check.db_pool = _mock_db_pool(mock_conn) + + # First fire at t=1000: lazy-seeded last_execution = 940, so scheduled = now -> lateness = 0. + check.data_observability.run_job() + lateness_metrics = aggregator.metrics('dd.postgres.data_observability.query_fire_lateness_seconds') + assert len(lateness_metrics) == 1 + assert lateness_metrics[0].value == 0.0 + assert 'mode:interval' in lateness_metrics[0].tags + + # After first fire, last_execution = 1000.0. + # Second fire at t=1080 (20s late: scheduled = 1000 + 60 = 1060, fired at 1080). + current_time[0] = 1080.0 + aggregator.reset() + check.data_observability.run_job() + + lateness_metrics = aggregator.metrics('dd.postgres.data_observability.query_fire_lateness_seconds') + assert len(lateness_metrics) == 1 + m = lateness_metrics[0] + assert 'mode:interval' in m.tags + # scheduled = last_run(1000) + interval(60) = 1060; fire at 1080 -> lateness = 20. + assert abs(m.value - 20.0) < 1.0 + + +def test_lateness_clamped_at_zero(pg_instance, aggregator, monkeypatch): + """If scheduled_fire_time is in the future at fire time (clock skew), lateness is clamped to 0.""" + current_time = [float(_BASE_EPOCH + 65)] # 00:50:05 — cron tick 00:50:00 in the past + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + + # Patch _get_due_queries to return a scheduled_fire_time in the future of fire_start + # (synthetic clock-skew scenario). Lateness must clamp to 0 rather than go negative. + from datadog_checks.postgres.data_observability import DueQuery + + skewed_scheduled = current_time[0] + 100.0 + q = check.data_observability._do_config.queries[0] + with patch.object( + check.data_observability, + '_get_due_queries', + return_value=[DueQuery(q, skewed_scheduled, "cron")], + ): + aggregator.reset() + check.data_observability.run_job() + + lateness_metrics = aggregator.metrics('dd.postgres.data_observability.query_fire_lateness_seconds') + assert len(lateness_metrics) == 1 + assert lateness_metrics[0].value == 0.0 + + +def test_starved_query_eventually_fires(pg_instance, aggregator, monkeypatch): + """Query B fires within the same hour even when Query A blocks run_job for >1 minute.""" + # Two cron queries: A at :50, B at :51 (both hourly). + query_a = { + **deepcopy(CRON_QUERY), + 'monitor_id': 50, + 'schedule': '50 * * * *', + } + query_b = { + **deepcopy(CRON_QUERY), + 'monitor_id': 51, + 'schedule': '51 * * * *', + } + + current_time = [float(_BASE_EPOCH)] # 00:49:00 + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _create_check(pg_instance, queries=[query_a, query_b]) + check.db_pool = _mock_db_pool(mock_conn) + + # First run: registers both next_run values (A -> 00:50:00, B -> 00:51:00), fires nothing. + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0 + + # Simulate: clock moves to 00:50:05 — A is due, B is not yet. + current_time[0] = _BASE_EPOCH + 65 # 00:50:05 + aggregator.reset() + check.data_observability.run_job() + + exec_metrics = aggregator.metrics('dd.postgres.data_observability.query_executions') + a_ran = any('monitor_id:50' in m.tags for m in exec_metrics) + b_ran = any('monitor_id:51' in m.tags for m in exec_metrics) + assert a_ran, "Query A should have fired at 00:50:05" + assert not b_ran, "Query B should not yet have fired at 00:50:05" + + # Simulate: A took 2m30s -> clock is now 00:52:35. B's tick (00:51:00) has passed. + current_time[0] = _BASE_EPOCH + 215 # 00:52:35 + aggregator.reset() + check.data_observability.run_job() + + exec_metrics = aggregator.metrics('dd.postgres.data_observability.query_executions') + b_ran_now = any('monitor_id:51' in m.tags for m in exec_metrics) + assert b_ran_now, "Query B should fire at 00:52:35 (late, but same hour)" + + # B's lateness should be positive (fired ~1m35s after its :51 tick) + lateness_metrics = aggregator.metrics('dd.postgres.data_observability.query_fire_lateness_seconds') + b_lateness = [m for m in lateness_metrics if 'monitor_id:51' in m.tags] + assert len(b_lateness) == 1 + assert b_lateness[0].value > 0.0 + + +# --------------------------------------------------------------------------- +# Cron startup lookback window (recovery of fires lost across check restarts) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "window_seconds,time_offset,expected_fires", + [ + (300, 70, 1), # inside window (10s after tick): fires + (0, 70, 0), # disabled: skips + (300, 400, 0), # outside window (340s after tick): skips + ], + ids=["inside-window", "disabled", "outside-window"], +) +def test_cron_startup_lookback_window_behavior( + pg_instance, aggregator, monkeypatch, window_seconds, time_offset, expected_fires +): + """Recovery fires only when window > 0 and (now - prev_tick) <= window.""" + current_time = [float(_BASE_EPOCH + time_offset)] + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance, window_seconds=window_seconds, monkeypatch=monkeypatch) + check.db_pool = _mock_db_pool(mock_conn) + check.data_observability.run_job() + + metrics = aggregator.metrics('dd.postgres.data_observability.query_executions') + assert len(metrics) == expected_fires + if expected_fires: + assert any('monitor_id:10' in m.tags for m in metrics) + + +def test_cron_startup_lookback_lateness_reflects_age_of_tick(pg_instance, aggregator, monkeypatch): + """Recovered fires emit a lateness gauge equal to (now - prev_tick).""" + # 00:50:30 — 30s after the 00:50 tick. + current_time = [float(_BASE_EPOCH + 90)] + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + check.data_observability.run_job() + + lateness_metrics = aggregator.metrics('dd.postgres.data_observability.query_fire_lateness_seconds') + assert len(lateness_metrics) == 1 + assert 25.0 <= lateness_metrics[0].value <= 35.0 + assert 'mode:cron' in lateness_metrics[0].tags + + +def test_cron_startup_lookback_default_is_300_seconds(): + """The startup lookback window is 5 minutes; pin it so a regression is loud.""" + from datadog_checks.postgres.data_observability import CRON_STARTUP_LOOKBACK_SECONDS + + assert CRON_STARTUP_LOOKBACK_SECONDS == 300 + + +def test_cron_startup_lookback_does_not_double_fire(pg_instance, aggregator, monkeypatch): + """A startup-recovery fire must not re-fire the same tick on the next run_job() call.""" + current_time = [float(_BASE_EPOCH + 70)] # 00:50:10 — inside the default 300s lookback + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 1 + + # Advance a few seconds (still well before the next 01:50 tick); a second run must not re-fire. + current_time[0] = _BASE_EPOCH + 80 + aggregator.reset() + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0 + + +# --------------------------------------------------------------------------- +# State reconciliation, mode caching, and error-path tests +# --------------------------------------------------------------------------- + + +def test_failed_cron_query_advances_next_run(pg_instance, aggregator, monkeypatch): + """A failing cron query still advances the scheduler, so it does not hot-loop on the same tick.""" + current_time = [float(_BASE_EPOCH + 65)] # 00:50:05 -- already past the 00:50 tick + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, mock_cursor = _make_mock_conn() + mock_cursor.execute.side_effect = psycopg.errors.ProgrammingError("relation does not exist") + + check = _make_cron_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + # First run: lookback recovery fires (5s within 300s window); scheduler caches next_tick = 01:50:00. + # The query fails; aggregator records an error metric that we discard below. + check.data_observability.run_job() + mid = CRON_QUERY['monitor_id'] + registered = check.data_observability._schedulers[mid].next_tick + + # Jump past the next tick and let the failing query fire again. + current_time[0] = _BASE_EPOCH + 3665 # ~01:50:05 + aggregator.reset() + check.data_observability.run_job() + metrics = aggregator.metrics('dd.postgres.data_observability.query_executions') + assert len(metrics) == 1 + assert 'status:error' in metrics[0].tags + + # next_tick must have advanced past the just-fired tick; otherwise the very next + # poll would re-fire the same tick in a tight loop. + advanced = check.data_observability._schedulers[mid].next_tick + assert advanced > registered + assert advanced >= current_time[0] + + +def test_cron_startup_lookback_boundary_inclusive(pg_instance, aggregator, monkeypatch): + """Recovery fires when now - prev_tick is within the window; does not fire outside it.""" + # prev_tick = 00:50:00; window = 60s; clock = 00:50:55 means (now - prev_tick) == 55s < window. + current_time = [float(_BASE_EPOCH + 115)] # 00:50:55 + monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0]) + + mock_conn, _ = _make_mock_conn() + check = _make_cron_check(pg_instance, monkeypatch=monkeypatch, window_seconds=60) + check.db_pool = _mock_db_pool(mock_conn) + check.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 1 + + # Outside the window: (now - prev_tick) == 61 > 60, recovery does NOT fire. + check2 = _make_cron_check(pg_instance, monkeypatch=monkeypatch, window_seconds=60) + check2.db_pool = _mock_db_pool(mock_conn) + current_time[0] = float(_BASE_EPOCH + 121) + aggregator.reset() + check2.data_observability.run_job() + assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0 + + +def test_emit_failures_metric_on_emit_path_exception(pg_instance, aggregator, monkeypatch): + """A broken emit path produces an emit_failures count tagged with exc_class.""" + mock_conn, _ = _make_mock_conn() + check = _create_check(pg_instance) + check.db_pool = _mock_db_pool(mock_conn) + + def boom(*args, **kwargs): + raise json.JSONDecodeError("boom", "doc", 0) + + monkeypatch.setattr('datadog_checks.postgres.data_observability.json.dumps', boom) + check.data_observability.run_job() + + failures = aggregator.metrics('dd.postgres.data_observability.emit_failures') + assert len(failures) == 1 + assert any(t.startswith('exc_class:JSONDecodeError') for t in failures[0].tags) + assert 'monitor_id:1' in failures[0].tags From bdac825807a95a07866524d97f7adee73db4757f Mon Sep 17 00:00:00 2001 From: Lucia Date: Tue, 9 Jun 2026 09:43:57 +0200 Subject: [PATCH 2/2] [AI-6411] Add HPE Aruba EdgeConnect integration (#23594) * Add initial scaffolding * Remove manifest * Add implementation * Remove unnecessary files * Fix claude's suggestions * Address Claude's comments * Optimiza calls * Fix mock * Fix validations and skip lab on E2E * Fix typo * Map traffic type to overlay * Address Claude's comments * Address Claude's comments * Fix tags * Uncompress fixtures * Removed unused appliance values * Sync conf example * Address Claude's comments * Sync conf example * Address Claude's comments * Fix README format * Sync conf example * Fix Claude's suggestion * Fix typing * Change metric format * Add concurrency to minute stats requests * Sync models * Remove None type option from namespace * Add parsing of tunnel color for edge cases * Add send_ndm_metadata * Remove unnecessary dd_save_state * Add _parse_config, config tags, and dedicated orch credentials - Add _parse_config method to resolve config once during initialization - Include user-configured tags in all emitted metrics - Replace reused HTTP basic auth username/password with dedicated orch_username/orch_password fields - Remove unnecessary auth=None override since basic auth fields are no longer populated Co-Authored-By: Claude Opus 4.6 * Sync config descriptions Co-Authored-By: Claude Opus 4.6 * Address comments * Validate ci * Refactor tests * docs(hpe_aruba_edgeconnect): add fleet_configurable flags and clarify permissions - Mark integration and all instance options as fleet_configurable in spec.yaml - Clarify README that Monitor permissions are sufficient for most metrics Rationale: Enable Fleet Automation support and correct misleading permission requirements This commit made by [/dd:git:commit:quick](https://github.com/DataDog/claude-marketplace/tree/main/dd/commands/git/commit/quick.md) * Add alarm events * Address comments * Address comments * Document persist connections and remove header * Update hpe_aruba_edgeconnect/README.md Co-authored-by: Janine Chan <64388808+janine-c@users.noreply.github.com> * Update hpe_aruba_edgeconnect/assets/configuration/spec.yaml Co-authored-by: Janine Chan <64388808+janine-c@users.noreply.github.com> * Update hpe_aruba_edgeconnect/assets/configuration/spec.yaml Co-authored-by: Janine Chan <64388808+janine-c@users.noreply.github.com> * Sync config --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Janine Chan <64388808+janine-c@users.noreply.github.com> --- .ddev/config.toml | 4 + .github/workflows/config/labeler.yml | 4 + .github/workflows/test-all.yml | 20 + code-coverage.datadog.yml | 3 + hpe_aruba_edgeconnect/CHANGELOG.md | 4 + hpe_aruba_edgeconnect/README.md | 90 ++ .../assets/configuration/spec.yaml | 157 +++ hpe_aruba_edgeconnect/changelog.d/23594.added | 1 + .../hpe_aruba_edgeconnect/__about__.py | 4 + .../hpe_aruba_edgeconnect/__init__.py | 7 + .../hpe_aruba_edgeconnect/appliance_models.py | 158 +++ .../hpe_aruba_edgeconnect/check.py | 502 ++++++++++ .../hpe_aruba_edgeconnect/client.py | 155 +++ .../config_models/__init__.py | 24 + .../config_models/defaults.py | 100 ++ .../config_models/instance.py | 157 +++ .../config_models/shared.py | 45 + .../config_models/validators.py | 36 + .../hpe_aruba_edgeconnect/constants.py | 77 ++ .../data/conf.yaml.example | 470 +++++++++ .../hpe_aruba_edgeconnect/metrics_store.py | 55 ++ .../hpe_aruba_edgeconnect/minute_stats.py | 921 ++++++++++++++++++ .../hpe_aruba_edgeconnect/ndm_models.py | 227 +++++ .../hpe_aruba_edgeconnect/utils.py | 27 + hpe_aruba_edgeconnect/hatch.toml | 14 + hpe_aruba_edgeconnect/metadata.csv | 72 ++ hpe_aruba_edgeconnect/pyproject.toml | 60 ++ hpe_aruba_edgeconnect/tests/__init__.py | 3 + hpe_aruba_edgeconnect/tests/common.py | 635 ++++++++++++ hpe_aruba_edgeconnect/tests/conftest.py | 147 +++ .../tests/docker/docker-compose.yaml | 25 + .../tests/docker/fake_appliance/Dockerfile | 28 + .../tests/docker/fake_appliance/app.py | 193 ++++ .../docker/fake_appliance/requirements.txt | 1 + .../tests/docker/fake_orch/Dockerfile | 21 + .../tests/docker/fake_orch/app.py | 102 ++ .../tests/docker/fake_orch/requirements.txt | 1 + .../fixtures/st2-100000000/appperf_v2.txt | 2 + .../tests/fixtures/st2-100000000/dscp.csv | 6 + .../fixtures/st2-100000000/dscp_peak.csv | 6 + .../fixtures/st2-100000000/interface.csv | 9 + .../st2-100000000/interface_overlay.csv | 11 + .../fixtures/st2-100000000/interface_peak.csv | 9 + .../tests/fixtures/st2-100000000/jitter.csv | 32 + .../tests/fixtures/st2-100000000/mos.csv | 32 + .../tests/fixtures/st2-100000000/probe_v2.txt | 12 + .../tests/fixtures/st2-100000000/shaper.csv | 5 + .../st2-100000000/tunnel_availability_v2.txt | 31 + .../fixtures/st2-100000000/tunnel_peak.csv | 34 + .../fixtures/st2-100000000/tunnel_v2.txt | 33 + .../fixtures/st2-100000060/appperf_v2.txt | 2 + .../tests/fixtures/st2-100000060/dscp.csv | 5 + .../fixtures/st2-100000060/dscp_peak.csv | 5 + .../fixtures/st2-100000060/interface.csv | 6 + .../st2-100000060/interface_overlay.csv | 10 + .../fixtures/st2-100000060/interface_peak.csv | 6 + .../tests/fixtures/st2-100000060/jitter.csv | 32 + .../tests/fixtures/st2-100000060/mos.csv | 32 + .../tests/fixtures/st2-100000060/probe_v2.txt | 12 + .../tests/fixtures/st2-100000060/shaper.csv | 3 + .../st2-100000060/tunnel_availability_v2.txt | 31 + .../fixtures/st2-100000060/tunnel_peak.csv | 34 + .../fixtures/st2-100000060/tunnel_v2.txt | 33 + hpe_aruba_edgeconnect/tests/test_e2e.py | 30 + hpe_aruba_edgeconnect/tests/test_unit.py | 697 +++++++++++++ 65 files changed, 5710 insertions(+) create mode 100644 hpe_aruba_edgeconnect/CHANGELOG.md create mode 100644 hpe_aruba_edgeconnect/README.md create mode 100644 hpe_aruba_edgeconnect/assets/configuration/spec.yaml create mode 100644 hpe_aruba_edgeconnect/changelog.d/23594.added create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__about__.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__init__.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/appliance_models.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/check.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/client.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/__init__.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/defaults.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/instance.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/shared.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/validators.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/constants.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/data/conf.yaml.example create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/metrics_store.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/minute_stats.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/ndm_models.py create mode 100644 hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/utils.py create mode 100644 hpe_aruba_edgeconnect/hatch.toml create mode 100644 hpe_aruba_edgeconnect/metadata.csv create mode 100644 hpe_aruba_edgeconnect/pyproject.toml create mode 100644 hpe_aruba_edgeconnect/tests/__init__.py create mode 100644 hpe_aruba_edgeconnect/tests/common.py create mode 100644 hpe_aruba_edgeconnect/tests/conftest.py create mode 100644 hpe_aruba_edgeconnect/tests/docker/docker-compose.yaml create mode 100644 hpe_aruba_edgeconnect/tests/docker/fake_appliance/Dockerfile create mode 100644 hpe_aruba_edgeconnect/tests/docker/fake_appliance/app.py create mode 100644 hpe_aruba_edgeconnect/tests/docker/fake_appliance/requirements.txt create mode 100644 hpe_aruba_edgeconnect/tests/docker/fake_orch/Dockerfile create mode 100644 hpe_aruba_edgeconnect/tests/docker/fake_orch/app.py create mode 100644 hpe_aruba_edgeconnect/tests/docker/fake_orch/requirements.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/appperf_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp_peak.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_overlay.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_peak.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/jitter.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/mos.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/probe_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/shaper.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_availability_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_peak.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/appperf_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp_peak.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_overlay.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_peak.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/jitter.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/mos.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/probe_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/shaper.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_availability_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_peak.csv create mode 100644 hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_v2.txt create mode 100644 hpe_aruba_edgeconnect/tests/test_e2e.py create mode 100644 hpe_aruba_edgeconnect/tests/test_unit.py diff --git a/.ddev/config.toml b/.ddev/config.toml index 92c39bef62c2b..292b02c7825f0 100644 --- a/.ddev/config.toml +++ b/.ddev/config.toml @@ -43,6 +43,7 @@ krakend = "KrakenD" lustre = "Lustre" prefect = "Prefect" n8n = "n8n" +hpe_aruba_edgeconnect = "HPE Aruba EdgeConnect" control_m = "Control-M" nifi = "Apache NiFi" @@ -53,6 +54,7 @@ prefect = "prefect.server." n8n = "n8n." control_m = "control_m." nifi = "nifi." +hpe_aruba_edgeconnect = "hpe_aruba_edgeconnect." [overrides.ci.ddev] platforms = ["linux", "windows"] @@ -271,3 +273,5 @@ prefect = ["linux", "windows", "mac_os"] n8n = ["linux", "windows", "mac_os"] control_m = ["linux", "windows", "mac_os"] nifi = ["linux", "windows", "mac_os"] +hpe_aruba_edgeconnect = ["linux", "windows", "mac_os"] + diff --git a/.github/workflows/config/labeler.yml b/.github/workflows/config/labeler.yml index de7ece88baa76..127baf99c71be 100644 --- a/.github/workflows/config/labeler.yml +++ b/.github/workflows/config/labeler.yml @@ -657,6 +657,10 @@ integration/hivemq: - changed-files: - any-glob-to-any-file: - hivemq/**/* +integration/hpe_aruba_edgeconnect: +- changed-files: + - any-glob-to-any-file: + - hpe_aruba_edgeconnect/**/* integration/http_check: - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 0fdcf02cfa0a4..5c9632f42192f 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -1618,6 +1618,26 @@ jobs: minimum-base-package: ${{ inputs.minimum-base-package }} pytest-args: ${{ inputs.pytest-args }} secrets: inherit + j0be27fe: + uses: ./.github/workflows/test-target.yml + with: + job-name: HPE Aruba EdgeConnect + target: hpe_aruba_edgeconnect + platform: linux + runner: '["ubuntu-22.04"]' + repo: "${{ inputs.repo }}" + context: ${{ inputs.context }} + python-version: "${{ inputs.python-version }}" + latest: ${{ inputs.latest }} + agent-image: "${{ inputs.agent-image }}" + agent-image-py2: "${{ inputs.agent-image-py2 }}" + agent-image-windows: "${{ inputs.agent-image-windows }}" + agent-image-windows-py2: "${{ inputs.agent-image-windows-py2 }}" + test-py2: ${{ inputs.test-py2 }} + test-py3: ${{ inputs.test-py3 }} + minimum-base-package: ${{ inputs.minimum-base-package }} + pytest-args: ${{ inputs.pytest-args }} + secrets: inherit j56d6f32: uses: ./.github/workflows/test-target.yml with: diff --git a/code-coverage.datadog.yml b/code-coverage.datadog.yml index 0de01c2ce72c4..d670091a3135f 100644 --- a/code-coverage.datadog.yml +++ b/code-coverage.datadog.yml @@ -322,6 +322,9 @@ services: - id: hdfs_namenode paths: - hdfs_namenode/datadog_checks/hdfs_namenode/ +- id: hpe_aruba_edgeconnect + paths: + - hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/ - id: http_check paths: - http_check/datadog_checks/http_check/ diff --git a/hpe_aruba_edgeconnect/CHANGELOG.md b/hpe_aruba_edgeconnect/CHANGELOG.md new file mode 100644 index 0000000000000..8a59a84828617 --- /dev/null +++ b/hpe_aruba_edgeconnect/CHANGELOG.md @@ -0,0 +1,4 @@ +# CHANGELOG - HPE Aruba EdgeConnect + + + diff --git a/hpe_aruba_edgeconnect/README.md b/hpe_aruba_edgeconnect/README.md new file mode 100644 index 0000000000000..09af988d701d3 --- /dev/null +++ b/hpe_aruba_edgeconnect/README.md @@ -0,0 +1,90 @@ +# Agent Check: HPE Aruba EdgeConnect + +## Overview + +This check monitors [HPE Aruba EdgeConnect][1] through the Datadog Agent. + +HPE Aruba EdgeConnect is an SD-WAN platform used to connect branch offices, data centers, and cloud environments through an overlay of secure tunnels managed centrally by an Orchestrator. This integration authenticates against the Orchestrator and the individual EdgeConnect appliances, collects health and performance metrics from each appliance's REST API and minute-stats archives, and reports the topology of the SD-WAN fabric (devices, interfaces, and tunnels) to Network Device Monitoring (NDM). + +### What this integration monitors + +The integration collects metrics across multiple layers of the EdgeConnect SD-WAN fabric, including: + +- **Orchestrator and appliance inventory**: Discovers all EdgeConnect appliances managed by the Orchestrator. Surfaces reachability status, uptime, hostname, site, model, and software version. +- **Appliance health**: Reports CPU, memory, and disk usage percentages, as well as hardware alarm state, to detect overloaded or failing devices. +- **Network interfaces**: Provides administrative and operational status, configured speed, RX/TX bandwidth and rate, peak and average utilization, and forward-drop counters per interface. +- **SD-WAN tunnels**: Reports per-tunnel latency, jitter, packet loss (pre- and post-FEC), Mean Opinion Score (MOS) for voice-quality tracking, downtime during the interval, and bidirectional throughput in both bits and packets per second. Allows detection of path degradation and SLA violations across the overlay. +- **Internet breakout tunnels**: Monitors RX/TX bandwidth, peak rates, and configured maximum throughput on local-internet breakout tunnels to observe direct-to-cloud traffic. +- **QoS and traffic shaping**: Tracks per-DSCP-class bandwidth and rate counters, shaper drop counts, and drop percentages. Useful for validating QoS policies and spotting classes being starved or over-subscribed. +- **Circuit SLA probes**: Reports average latency, jitter, and packet loss from SLA probes, plus next-hop administrative and operational status, to monitor underlay link quality independently of the overlay. +- **Application performance**: Provides per-application latency, enabling drill-down from tunnel or interface issues to the specific affected applications. +- **Network Device Monitoring topology**: Pushes device, interface, and tunnel metadata to NDM, enabling visualization of the SD-WAN fabric and correlation with the rest of the network. + +## Setup + +Follow the instructions below to install and configure this check for an Agent running on a host. For containerized environments, see the [Autodiscovery Integration Templates][3] for guidance on applying these instructions. + +### Installation + +The HPE Aruba EdgeConnect check is included in the [Datadog Agent][2] package. +No additional installation is needed on your server. + +### Configuration + +1. Edit the `hpe_aruba_edgeconnect.d/conf.yaml` file, in the `conf.d/` folder at the root of your Agent's configuration directory to start collecting your HPE Aruba EdgeConnect performance data. See the [sample hpe_aruba_edgeconnect.d/conf.yaml][4] for all available configuration options. + + **Note**: Monitor permissions are enough to collect most metrics. Admin permissions are required to collect the `hpe_aruba_edgeconnect.device.cpu.usage` metric on the appliances. If the configured credentials do not have admin access, the Agent skips this metric, but collects the rest of the metrics. + +2. [Restart the Agent][5]. + +### Validation + +[Run the Agent's status subcommand][6] and look for `hpe_aruba_edgeconnect` under the Checks section. + +## Data Collected + +### Metrics + +See [metadata.csv][7] for a list of metrics provided by this integration. + +### Logs + +The recommended way to collect logs from HPE Aruba EdgeConnect is to configure the appliance to forward logs through syslog, as described in the [HPE Aruba Networking documentation][8]. + +1. Enable log collection in your `datadog.yaml` file: + + ```yaml + logs_enabled: true + ``` +2. Uncomment and edit the logs configuration block in your `hpe_aruba_edgeconnect.d/conf.yaml` file. For example: + + ```yaml + logs: + - type: tcp + port: 10514 + source: hpe_aruba_edgeconnect + service: + ``` + +### Events + +The HPE Aruba EdgeConnect integration includes alarm event support. Events are disabled by default; to enable them, set `collect_events` to `true` in the configuration. + + +### Service Checks + +The HPE Aruba EdgeConnect integration does not include any service checks. + +## Troubleshooting + +Need help? Contact [Datadog support][9]. + +[1]: https://www.hpe.com/us/en/aruba-edgeconnect-sd-wan.html +[2]: https://app.datadoghq.com/account/settings/agent/latest +[3]: https://docs.datadoghq.com/containers/kubernetes/integrations/ +[4]: https://github.com/DataDog/integrations-core/blob/master/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/data/conf.yaml.example +[5]: https://docs.datadoghq.com/agent/configuration/agent-commands/#start-stop-and-restart-the-agent +[6]: https://docs.datadoghq.com/agent/configuration/agent-commands/#agent-status-and-information +[7]: https://github.com/DataDog/integrations-core/blob/master/hpe_aruba_edgeconnect/metadata.csv +[8]: https://arubanetworking.hpe.com/techdocs/sdwan/docs/orch/support/tech-assistance/remote-log-msgs/ +[9]: https://docs.datadoghq.com/help/ diff --git a/hpe_aruba_edgeconnect/assets/configuration/spec.yaml b/hpe_aruba_edgeconnect/assets/configuration/spec.yaml new file mode 100644 index 0000000000000..68e4bff75b5a4 --- /dev/null +++ b/hpe_aruba_edgeconnect/assets/configuration/spec.yaml @@ -0,0 +1,157 @@ +name: HPE Aruba EdgeConnect +fleet_configurable: true +files: +- name: hpe_aruba_edgeconnect.yaml + options: + - template: init_config + options: + - template: init_config/default + - template: instances + options: + - name: orchestrator_ip + fleet_configurable: true + required: true + description: Hostname or IP address of the HPE Aruba EdgeConnect Orchestrator. + value: + type: string + example: 10.0.0.1 + + - name: orchestrator_username + fleet_configurable: true + required: true + description: | + Username for the Orchestrator. If no appliance-specific credentials are provided, + this username is used as the default for appliances. + value: + type: string + + - name: orchestrator_password + fleet_configurable: true + required: true + secret: true + description: | + Password for the Orchestrator. If no appliance-specific credentials are provided, + this password is used as the default for appliances. + value: + type: string + + - name: namespace + fleet_configurable: true + description: Namespace to use for NDM device metadata. Defaults to "default". + value: + type: string + example: default + default: default + - name: appliance_ips + fleet_configurable: true + description: | + HPE Aruba EdgeConnect appliance IP filters. CIDR entries match any appliance IP within the block. + If no filters are configured, all discovered appliances are included. + Use `include` to specify which appliances to poll and `exclude` to skip individual appliances. + `exclude` takes precedence over `include`. + value: + type: object + properties: + - name: include + type: array + items: + type: string + - name: exclude + type: array + items: + type: string + + example: + include: + - 10.0.0.0/24 + - 192.168.1.5 + exclude: + - 10.0.0.99 + - name: max_concurrency + fleet_configurable: true + description: Maximum number of appliances to poll concurrently. + value: + type: integer + minimum: 1 + default: 50 + example: 50 + - name: max_backfill_minutes + fleet_configurable: true + description: | + How many minutes of historical data to recover for each appliance after the Agent has been + offline. If the Agent stops collecting for a while (for example, during a restart or an + outage), this controls how far back in time it looks when it starts up again. + + For example, with the default of 5, the Agent collects at most the last 5 minutes + of data per appliance after coming back online. Anything older than that is skipped, and + collection resumes normally from the most recent point. + value: + type: integer + minimum: 1 + default: 5 + example: 5 + - name: appliance_credentials_overrides + fleet_configurable: true + description: | + Per-appliance credential overrides matched by CIDR. When an appliance IP falls within a listed + CIDR block, the associated username and password are used instead of the main credentials. + If multiple CIDR blocks match, the Agent uses the first match. Each entry must specify all three + fields: `cidr`, `username`, and `password`. + value: + type: array + items: + type: object + required: + - cidr + - username + - password + properties: + - name: cidr + type: string + - name: username + type: string + - name: password + type: string + secret: true + example: + - cidr: 10.0.0.0/24 + username: admin + password: secret + - template: instances/http + overrides: + persist_connections.value.default: true + persist_connections.value.example: true + persist_connections.description: | + Connections are always persisted to use cookies and connection pooling for improved performance. + - template: instances/default + overrides: + min_collection_interval.value.example: 60 + min_collection_interval.value.display_default: 60 + min_collection_interval.description: | + Changes the collection interval of the check. For more information, see + https://docs.datadoghq.com/developers/write_agent_check/#collection-interval. + The default is 60 seconds. Statistics are only available at one-minute intervals, + so more frequent runs do not collect additional data. + + - name: send_ndm_metadata + fleet_configurable: true + description: | + Set to `true` to enable Network Device Monitoring metadata (for devices, interfaces, topology) to be sent. + value: + type: boolean + example: False + default: False + - name: collect_events + fleet_configurable: true + description: | + Set to `true` to enable collection of HPE Aruba EdgeConnect alarm events. + value: + type: boolean + example: False + default: False + - template: logs + example: + - type: tcp + port: 10514 + service: hpe_aruba_edgeconnect + source: hpe_aruba_edgeconnect diff --git a/hpe_aruba_edgeconnect/changelog.d/23594.added b/hpe_aruba_edgeconnect/changelog.d/23594.added new file mode 100644 index 0000000000000..aa949b47b7b41 --- /dev/null +++ b/hpe_aruba_edgeconnect/changelog.d/23594.added @@ -0,0 +1 @@ +Initial Release \ No newline at end of file diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__about__.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__about__.py new file mode 100644 index 0000000000000..e50f43adfb9b1 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__about__.py @@ -0,0 +1,4 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +__version__ = '0.0.1' diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__init__.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__init__.py new file mode 100644 index 0000000000000..c2b3643277203 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/__init__.py @@ -0,0 +1,7 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from .__about__ import __version__ +from .check import HpeArubaEdgeconnectCheck + +__all__ = ['__version__', 'HpeArubaEdgeconnectCheck'] diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/appliance_models.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/appliance_models.py new file mode 100644 index 0000000000000..f609de1f69f99 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/appliance_models.py @@ -0,0 +1,158 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import ipaddress +from collections.abc import Iterable, Iterator +from typing import TYPE_CHECKING, Any + +from .config_models.instance import ApplianceCredentialsOverride, ApplianceIps +from .constants import NDM_DEVICE_RESOURCE_TAG, NDM_DEVICE_USER_TAGS_RESOURCE_TAG + +if TYPE_CHECKING: + from datadog_checks.base.log import CheckLoggingAdapter + + +class Appliance: + __slots__ = ( + 'host_name', + 'ip', + 'serial', + 'model', + 'mode', + 'software_version', + 'state', + 'site', + 'username', + 'password', + ) + + def __init__(self, data: dict[str, Any]) -> None: + self.host_name: str = data.get('hostName', '') + self.ip: str = data.get('ip', '') + self.serial: str = data.get('serial', '') + self.model: str = data.get('model', '') + self.mode: str = data.get('mode', '') + self.software_version: str = data.get('softwareVersion', '') + self.state: int = data.get('state', 0) + self.site: str | None = data.get('site') + self.username: str = '' + self.password: str = '' + + @property + def is_reachable(self) -> bool: + # Appliance state to Orchestrator value mapping: + # 0 - Unknown ( When an appliance is added to Orchestrator, it + # is in this state ) + # 1 - Normal ( Appliance is reachable from Orchestrator) + # 2 - Unreachable ( Appliance is unreachable from Orchestrator ) + # 3 - Unsupported Version ( Orchestrator does not support this + # version of the appliance ) + # 4 - Out of Synchronization ( Orchestrator's cache of appliance + # configuration/state is out of sync with the + # configuration/state on the appliance ) + # 5 - Synchronization in Progress ( Orchestrator is currently + # synchronizing appliances's configuration and state ) + return self.state == 1 + + def device_id(self, namespace: str) -> str: + return f'{namespace}:{self.ip}' + + def tags(self, namespace: str) -> list[str]: + site = self.site or 'unknown' + did = self.device_id(namespace) + return [ + f'device_namespace:{namespace}', + f'device_ip:{self.ip}', + f'device_model:{self.model or "unknown"}', + f'device_hostname:{self.host_name or "unknown"}', + f'software_version:{self.software_version or "unknown"}', + 'device_vendor:aruba', + f'site_id:{site}', + f'site_name:{site}', + f'{NDM_DEVICE_RESOURCE_TAG}:{did}', + f'{NDM_DEVICE_USER_TAGS_RESOURCE_TAG}:{did}', + ] + + +class Appliances: + """Collection of appliances with filtering and credential resolution.""" + + def __init__(self, raw: list[Appliance], log: CheckLoggingAdapter) -> None: + self._appliances = list(raw) + self.log = log + + def __iter__(self) -> Iterator[Appliance]: + return iter(self._appliances) + + def __len__(self) -> int: + return len(self._appliances) + + def filter(self, ip_filter: ApplianceIps | None) -> None: + if not ip_filter: + return + if ip_filter.include: + kept = [a for a in self._appliances if _ip_matches_any(a.ip, ip_filter.include)] + removed = [a for a in self._appliances if a not in kept] + if removed: + self.log.debug( + "Filtered out %d appliance(s) not matching include filter: %s", + len(removed), + ', '.join(a.ip for a in removed), + ) + self._appliances = kept + if ip_filter.exclude: + kept = [a for a in self._appliances if not _ip_matches_any(a.ip, ip_filter.exclude)] + removed = [a for a in self._appliances if a not in kept] + if removed: + self.log.debug( + "Filtered out %d appliance(s) matching exclude filter: %s", + len(removed), + ', '.join(a.ip for a in removed), + ) + self._appliances = kept + + def resolve_credentials( + self, + default_username: str, + default_password: str, + overrides: Iterable[ApplianceCredentialsOverride] | None = None, + ) -> None: + overrides = list(overrides or []) + for appliance in self._appliances: + appliance.username = default_username + appliance.password = default_password + for cred in overrides: + try: + if ipaddress.ip_address(appliance.ip) in ipaddress.ip_network(cred.cidr, strict=False): + appliance.username = cred.username + appliance.password = cred.password + self.log.debug( + "Using CIDR-matched credentials for appliance %s (matched %s)", + appliance.ip, + cred.cidr, + ) + break + except (ValueError, TypeError): + self.log.warning("Invalid CIDR %s for appliance %s, skipping", cred.cidr, appliance.ip) + continue + else: + self.log.debug("Using shared credentials for appliance %s", appliance.ip) + + +def _ip_matches_any(ip: str, patterns: Iterable[str]) -> bool: + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + for pattern in patterns: + try: + if '/' in pattern: + if addr in ipaddress.ip_network(pattern, strict=False): + return True + elif addr == ipaddress.ip_address(pattern): + return True + except ValueError: + continue + return False diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/check.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/check.py new file mode 100644 index 0000000000000..2e591bdbde153 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/check.py @@ -0,0 +1,502 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import json +import threading +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +from datadog_checks.base import AgentCheck +from datadog_checks.base.utils.http import RequestsWrapper + +from .appliance_models import Appliance, Appliances +from .client import ApplianceClient, OrchestratorClient +from .config_models import ConfigMixin +from .constants import ( + ALARM_SEVERITY_BY_ID, + ALARM_SEVERITY_TO_ALERT_TYPE, + CPU_STATE_FIELDS, + MINUTE_STATS_INTERVAL, + NDM_INTERFACE_RESOURCE_TAG, +) +from .metrics_store import MetricsStore +from .minute_stats import MinuteStats, TunnelV2Stats +from .ndm_models import ( + DeviceMetadata, + InterfaceMetadata, + TunnelMetadata, + batch_payloads, + create_device_metadata, + create_interface_metadata, + create_tunnel_metadata, +) +from .utils import parse_speed + + +class HpeArubaEdgeconnectCheck(AgentCheck, ConfigMixin): + __NAMESPACE__ = 'hpe_aruba_edgeconnect' + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.http.persist_connections = True + self._peer_lookup: dict[str, tuple[str, str]] = {} + self._overlay_map: dict[str, str] = {} + self._traffic_class_map: dict[str, str] = {} + self._appliance_clients: dict[str, ApplianceClient] = {} + self._orch_client: OrchestratorClient | None = None + self._appliance_clients_lock = threading.Lock() + self.check_initializations.append(self._parse_config) + + def _parse_config(self) -> None: + self.tags = list(self.config.tags or []) + [f'orch_ip:{self.config.orchestrator_ip}'] + self.namespace = self.config.namespace or 'default' + self.max_backfill = self.config.max_backfill_minutes or 5 + + def _get_orch_client(self) -> OrchestratorClient: + if self._orch_client is not None: + return self._orch_client + client = OrchestratorClient(self.http, self.config.orchestrator_ip) + client.login(self.config.orchestrator_username, self.config.orchestrator_password) + self._orch_client = client + return client + + def check(self, _: Any) -> None: + try: + orch_client = self._get_orch_client() + except Exception: + self.gauge('orchestrator.reachability', 0, tags=self.tags) + self._orch_client = None + raise + self.gauge('orchestrator.reachability', 1, tags=self.tags) + appliances = self._collect_appliances_from_orch(orch_client) + self._remove_stale_appliance_clients({ap.ip for ap in appliances}) + with ThreadPoolExecutor(max_workers=self.config.max_concurrency) as pool: + futs = { + pool.submit( + self._collect_appliance, + ap, + self._peer_lookup, + self._overlay_map, + self._traffic_class_map, + ): ap + for ap in appliances + } + for f in as_completed(futs): + ap = futs[f] + try: + f.result() + except Exception: + with self._appliance_clients_lock: + self._appliance_clients.pop(ap.ip, None) + self.log.warning("Failed to collect from appliance %s", ap.ip, exc_info=True) + + def _remove_stale_appliance_clients(self, current_ips: set[str]) -> None: + stale_ips = set(self._appliance_clients) - current_ips + for ip in stale_ips: + del self._appliance_clients[ip] + + def _submit_metadata( + self, + items: list[DeviceMetadata] | list[InterfaceMetadata] | list[TunnelMetadata], + collect_timestamp: int | None = None, + ) -> None: + if not items or not self.config.send_ndm_metadata: + return + for batch in batch_payloads(self.namespace, items, collect_timestamp): + self.event_platform_event( + json.dumps(batch.model_dump(exclude_none=True)), + "network-devices-metadata", + ) + + def _collect_appliances_from_orch(self, client: OrchestratorClient) -> Appliances: + raw_appliances = client.get_appliances() + if not raw_appliances: + self.log.warning("No appliances returned from orchestrator %s", self.config.orchestrator_ip) + return Appliances([], self.log) + self.log.debug("Found %d appliances from orchestrator before filtering", len(raw_appliances)) + all_appliances = [Appliance(a) for a in raw_appliances] + self._peer_lookup = {ap.host_name: (ap.ip, ap.site or 'unknown') for ap in all_appliances if ap.host_name} + appliances = Appliances(all_appliances, self.log) + appliances.filter(self.config.appliance_ips) + self.log.debug("Monitoring %d appliances after filtering: %s", len(appliances), [ap.ip for ap in appliances]) + appliances.resolve_credentials( + self.config.orchestrator_username, + self.config.orchestrator_password, + self.config.appliance_credentials_overrides, + ) + namespace = self.namespace + devices: list[DeviceMetadata] = [] + for ap in appliances: + tags = self.tags + ap.tags(namespace) + self.gauge('device.reachability', 1 if ap.is_reachable else 0, tags=tags) + if self.config.send_ndm_metadata: + devices.append(create_device_metadata(ap, namespace)) + if self.config.send_ndm_metadata: + self._submit_metadata(devices) + try: + self._overlay_map, self._traffic_class_map = client.get_overlay_config() + except Exception: + self.log.warning( + "Failed to fetch overlay config; tunnel and shaper metrics will be emitted without the " + "overlay_name tag.", + exc_info=True, + ) + self._overlay_map = {} + self._traffic_class_map = {} + return appliances + + def _create_appliance_client(self, app_ip: str, username: str, password: str) -> ApplianceClient: + cached = self._appliance_clients.get(app_ip) + if cached is not None: + return cached + http = RequestsWrapper(self.instance or {}, self.init_config, self.HTTP_CONFIG_REMAPPER, self.log) + http.persist_connections = True + client = ApplianceClient(http, app_ip, self.log) + client.login(username, password) + with self._appliance_clients_lock: + return self._appliance_clients.setdefault(app_ip, client) + + def _timestamps_to_fetch(self, app_ip: str, newest: int) -> list[int]: + raw = self.read_persistent_cache(f'last_timestamp:{app_ip}') + last_ts = int(raw) if raw else None + + if last_ts is not None and newest == last_ts: + return [] + + if last_ts is None: + self.log.debug("First run for %s, collecting only the newest timestamp %d", app_ip, newest) + return [newest] + + timestamps: list[int] = [] + ts = newest + while ts > last_ts and len(timestamps) < self.max_backfill: + timestamps.append(ts) + ts -= MINUTE_STATS_INTERVAL + + if ts > last_ts: + self.log.warning( + "Appliance %s is %d minutes behind; capping backfill at %d minute(s). Older data will be skipped.", + app_ip, + (newest - last_ts) // MINUTE_STATS_INTERVAL, + self.max_backfill, + ) + else: + self.log.debug( + "Catching up %d missed minute-stats archives for %s (last cached: %d, newest: %d)", + len(timestamps), + app_ip, + last_ts, + newest, + ) + return timestamps + + def _collect_minute_stats( + self, + client: ApplianceClient, + app_ip: str, + timestamps: list[int], + base_tags: list[str], + device_id: str, + traffic_class_map: dict[str, str], + overlay_map: dict[str, str], + ) -> tuple[list[TunnelV2Stats], int | None]: + store = MetricsStore() + latest_tunnel_stats: list[TunnelV2Stats] = [] + latest_tunnel_stats_ts: int | None = None + last_successful_ts: int | None = None + contents: dict[int, bytes] = {} + failed_ts: set[int] = set() + max_dl = min(len(timestamps), 5) + with ThreadPoolExecutor(max_workers=max_dl) as pool: + dl_futs = {pool.submit(client.get_minute_stats, f'st2-{ts}.tgz'): ts for ts in timestamps} + for fut in as_completed(dl_futs): + ts = dl_futs[fut] + try: + contents[ts] = fut.result() + except Exception: + self.log.warning( + "Failed to download minute-stats archive st2-%d.tgz for appliance %s, skipping", + ts, + app_ip, + exc_info=True, + ) + failed_ts.add(ts) + for ts in reversed(timestamps): + if ts in failed_ts: + continue + try: + minute_stats = MinuteStats(contents[ts], app_ip, ts, self.log) + minute_stats.record(store, base_tags, device_id, traffic_class_map, overlay_map) + except Exception: + self.log.warning( + "Failed to process minute-stats archive st2-%d.tgz for appliance %s, skipping", + ts, + app_ip, + exc_info=True, + ) + continue + last_successful_ts = ts + if minute_stats.tunnels: + latest_tunnel_stats = minute_stats.tunnels + latest_tunnel_stats_ts = ts + store.flush(self) + if last_successful_ts is not None: + self.write_persistent_cache(f'last_timestamp:{app_ip}', str(last_successful_ts)) + return latest_tunnel_stats, latest_tunnel_stats_ts + + def _collect_appliance( + self, + appliance: Appliance, + peer_lookup: dict[str, tuple[str, str]], + overlay_map: dict[str, str], + traffic_class_map: dict[str, str], + ) -> None: + app_ip = appliance.ip + self.log.debug("Starting collection for appliance %s", app_ip) + client = self._create_appliance_client(app_ip, appliance.username, appliance.password) + + namespace = self.namespace + base_tags = self.tags + appliance.tags(namespace) + device_id = appliance.device_id(namespace) + newest = client.get_newest_timestamp() + + timestamps = self._timestamps_to_fetch(app_ip, newest) + if timestamps: + latest_tunnel_stats, latest_tunnel_stats_ts = self._collect_minute_stats( + client, app_ip, timestamps, base_tags, device_id, traffic_class_map, overlay_map + ) + else: + latest_tunnel_stats = [] + latest_tunnel_stats_ts = None + self.log.debug("Appliance %s stats are up to date (last timestamp: %d)", app_ip, newest) + + collectors: list[tuple[str, Callable[[], None]]] = [ + ('network_interfaces', lambda: self._collect_network_interfaces(client, base_tags, device_id, newest)), + ('cpu_stats', lambda: self._collect_cpu_stats(client, base_tags, newest)), + ('memory_stats', lambda: self._collect_memory_stats(client, base_tags)), + ('disk_stats', lambda: self._collect_disk_stats(client, base_tags)), + ('alarm_stats', lambda: self._collect_alarm_stats(client, base_tags)), + ('system_info', lambda: self._collect_system_info(client, base_tags)), + ] + if latest_tunnel_stats and latest_tunnel_stats_ts and self.config.send_ndm_metadata: + collectors.append( + ( + 'tunnel_metadata', + lambda: self._collect_tunnel_metadata( + client, + appliance, + latest_tunnel_stats, + peer_lookup, + overlay_map, + namespace, + latest_tunnel_stats_ts, + ), + ) + ) + max_workers = min(len(collectors), self.config.max_concurrency or len(collectors)) + with ThreadPoolExecutor(max_workers=max_workers) as pool: + futs = {pool.submit(fn): name for name, fn in collectors} + for f in as_completed(futs): + try: + f.result() + except Exception: + self.log.warning("Collection step %s failed for appliance %s", futs[f], app_ip, exc_info=True) + + def _collect_tunnel_metadata( + self, + client: ApplianceClient, + appliance: Appliance, + tunnel_stats: list[TunnelV2Stats], + peer_lookup: dict[str, tuple[str, str]], + overlay_map: dict[str, str], + namespace: str, + collect_timestamp: int, + ) -> None: + wan_labels: set[str] = set() + try: + labels = client.get_interface_labels() + except Exception: + self.log.warning( + "Failed to fetch interface labels for appliance %s; " + "tunnel_color will be empty for non-overlay tunnels.", + appliance.ip, + exc_info=True, + ) + else: + wan_entries = labels.get('wan') if isinstance(labels, dict) else None + if isinstance(wan_entries, dict): + wan_labels = {name for name in wan_entries.values() if isinstance(name, str) and name} + tunnels: list[TunnelMetadata] = [ + create_tunnel_metadata( + t, + appliance.ip, + appliance.site, + namespace, + peer_lookup, + overlay_map, + wan_labels, + self.log, + ) + for t in tunnel_stats + ] + self._submit_metadata(tunnels, collect_timestamp) + + def _collect_network_interfaces( + self, + client: ApplianceClient, + base_tags: list[str], + device_id: str, + collect_timestamp: int, + ) -> None: + data = client.get_network_interfaces() + namespace = self.namespace + interfaces: list[InterfaceMetadata] = [] + for iface in data.get('ifInfo', []): + ifname = iface.get('ifname', 'unknown') + iface_tags = base_tags + [ + f'interface_name:{ifname}', + f'{NDM_INTERFACE_RESOURCE_TAG}:{device_id}', + ] + for status_type, raw in (('admin', iface.get('admin')), ('oper', iface.get('oper'))): + if raw is None: + continue + self.gauge('interface.status', 1 if raw else 0, tags=iface_tags + [f'status_type:{status_type}']) + speed = parse_speed(iface.get('speed')) + if speed is not None: + self.gauge('interface.speed', speed, tags=iface_tags) + if self.config.send_ndm_metadata: + interfaces.append(create_interface_metadata(client.app_ip, iface, namespace)) + self._submit_metadata(interfaces, collect_timestamp) + + def _collect_cpu_stats(self, client: ApplianceClient, base_tags: list[str], newest: int) -> None: + cpu_data = client.get_cpu_stats(newest) + if not cpu_data: + return + buckets = cpu_data.get('data') or [] + if not buckets: + return + latest_ts = cpu_data.get('latestTimestamp') + bucket = next((b for b in buckets if str(latest_ts) in b), buckets[-1]) + entries: list[Any] = next(iter(bucket.values()), []) + aggregate = next((e for e in entries if str(e.get('cpu_number')).upper() == 'ALL'), None) + if aggregate is None: + self.log.warning("No aggregate CPU data found for appliance %s", client.app_ip) + return + for state, field in CPU_STATE_FIELDS.items(): + value = aggregate.get(field) + if value is None: + continue + try: + cpu = float(value) + except (TypeError, ValueError): + self.log.warning( + "Failed to convert CPU %s value %s to float for appliance %s, skipping", state, value, client.app_ip + ) + continue + self.gauge('device.cpu.usage', cpu, tags=base_tags + [f'cpu_state:{state}']) + + def _collect_memory_stats(self, client: ApplianceClient, base_tags: list[str]) -> None: + mem_data = client.get_memory_stats() + if not isinstance(mem_data, dict): + return + total_raw = mem_data.get('total') + used_raw = mem_data.get('used') + if total_raw is None or used_raw is None: + return + try: + total = float(total_raw) + used = float(used_raw) + except (TypeError, ValueError): + return + if total <= 0: + return + self.gauge('device.memory.usage', (used / total) * 100.0, tags=base_tags) + + def _collect_disk_stats(self, client: ApplianceClient, base_tags: list[str]) -> None: + disk_data = client.get_disk_usage() + if not isinstance(disk_data, dict): + return + for mount, entry in disk_data.items(): + if not isinstance(entry, dict): + continue + filesystem = entry.get('filesystem') + if filesystem in (None, 'none', 'tmpfs'): + continue + used_percent = entry.get('usedpercent') + if used_percent is None: + continue + try: + value = float(used_percent) + except (TypeError, ValueError): + continue + mount_tags = base_tags + [f'mount:{mount}', f'device:{filesystem}'] + self.gauge('device.disk.usage', value, tags=mount_tags) + + def _collect_system_info(self, client: ApplianceClient, base_tags: list[str]) -> None: + info = client.get_system_info() + if not isinstance(info, dict): + return + uptime_ms = info.get('uptime') + if uptime_ms is None: + self.log.warning("No uptime found for appliance %s", client.app_ip) + return + try: + self.gauge('device.uptime', float(uptime_ms) / 1000.0, tags=base_tags) + except (TypeError, ValueError): + return + + def _collect_alarm_stats(self, client: ApplianceClient, base_tags: list[str]) -> None: + alarms = client.get_alarms() + outstanding: list[dict[str, Any]] = [] + if isinstance(alarms, dict) and isinstance(alarms.get('outstanding'), list): + outstanding = [a for a in alarms['outstanding'] if isinstance(a, dict)] + hw_alarm = any(a.get('type') == 'HW' for a in outstanding) + if hw_alarm: + self.log.debug("Hardware alarm detected on appliance %s", client.app_ip) + self.gauge('device.hardware.ok', 0 if hw_alarm else 1, tags=base_tags) + if not self.config.collect_events: + return + cache_key = f'last_alarm_ts:{client.app_ip}' + raw = self.read_persistent_cache(cache_key) + last_ts = int(raw) if raw else 0 + newest_ts = last_ts + for alarm in outstanding: + raised_ms = alarm.get('time') + if isinstance(raised_ms, (int, float)): + if raised_ms <= last_ts: + continue + newest_ts = max(newest_ts, int(raised_ms)) + self._submit_alarm_event(alarm, client.app_ip, base_tags) + if newest_ts > last_ts: + self.write_persistent_cache(cache_key, str(newest_ts)) + + def _submit_alarm_event(self, alarm: dict[str, Any], app_ip: str, base_tags: list[str]) -> None: + severity_id = alarm.get('severity') + severity = ALARM_SEVERITY_BY_ID.get(severity_id, 'unknown') if isinstance(severity_id, int) else 'unknown' + alert_type = ALARM_SEVERITY_TO_ALERT_TYPE.get(severity, 'info') + description = alarm.get('description') or alarm.get('name') or 'Alarm' + recommendation = alarm.get('recommendation') + msg_text = f'{description}\n\nRecommendation: {recommendation}' if recommendation else description + tags = base_tags + [ + f'alarm_severity:{severity}', + f'alarm_source:{alarm.get("source", "unknown")}', + f'alarm_name:{alarm.get("name", "unknown")}', + ] + event: dict[str, Any] = { + 'event_type': alarm.get("type", "unknown"), + 'source_type_name': self.__NAMESPACE__, + 'msg_title': f'[HPE Aruba EdgeConnect] {severity.capitalize()}: {description}', + 'msg_text': msg_text, + 'alert_type': alert_type, + 'tags': tags, + } + sequence_id = alarm.get('sequenceId') + if sequence_id is not None: + event['aggregation_key'] = f'{app_ip}:{sequence_id}' + raised_ms = alarm.get('time') + if isinstance(raised_ms, (int, float)): + event['timestamp'] = int(raised_ms / 1000) + self.event(event) diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/client.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/client.py new file mode 100644 index 0000000000000..674e2fcff3032 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/client.py @@ -0,0 +1,155 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from requests import Response + +from datadog_checks.base.utils.http import RequestsWrapper + +if TYPE_CHECKING: + from datadog_checks.base.log import CheckLoggingAdapter + + +class _BaseClient: + """Shared HTTP client with transparent 401 re-login.""" + + def __init__(self, http: RequestsWrapper, base_url: str) -> None: + self._http = http + self._base_url = base_url + self._creds: tuple[str, str] | None = None + + def login(self, username: str, password: str) -> None: + self._do_login(username, password) + self._creds = (username, password) + + def _do_login(self, username: str, password: str) -> None: + raise NotImplementedError + + def _request(self, method: str, path: str, *, raise_on_error: bool = True, **kwargs: Any) -> Response: + """Issue an HTTP request, transparently re-logging in once on 401 (expired session).""" + url = f'{self._base_url}{path}' + send = getattr(self._http, method) + resp = send(url, **kwargs) + if resp.status_code == 401 and self._creds is not None: + self._do_login(*self._creds) + resp = send(url, **kwargs) + if raise_on_error: + resp.raise_for_status() + return resp + + +class OrchestratorClient(_BaseClient): + """HTTP client for the HPE Aruba EdgeConnect orchestrator API.""" + + def __init__(self, http: RequestsWrapper, orch_ip: str) -> None: + super().__init__(http, f'https://{orch_ip}') + + def _do_login(self, username: str, password: str) -> None: + resp = self._http.post( + f'{self._base_url}/gms/rest/authentication/login', + json={'user': username, 'password': password}, + ) + resp.raise_for_status() + csrf_token = self._http.session.cookies.get('orchCsrfToken') + if csrf_token: + self._http.session.headers.update({'X-XSRF-TOKEN': csrf_token}) + + def get_appliances(self) -> list[dict[str, Any]]: + resp = self._request('get', '/gms/rest/appliance') + return resp.json() + + def get_overlay_config(self) -> tuple[dict[str, str], dict[str, str]]: + """Fetch overlay configuration and derive id -> name mappings for overlays and traffic classes.""" + resp = self._request('get', '/gms/rest/gms/overlays/config') + data = resp.json() + overlay_map: dict[str, str] = {} + traffic_class_map: dict[str, str] = {} + if isinstance(data, list): + for entry in data: + if not isinstance(entry, dict): + continue + name = entry.get('name') + overlay_id = entry.get('id') + if overlay_id is not None and name: + overlay_map[str(overlay_id)] = name + traffic_class = entry.get('trafficClass') + if traffic_class is not None and name: + traffic_class_map.setdefault(str(traffic_class), name) + return overlay_map, traffic_class_map + + +class ApplianceClient(_BaseClient): + """HTTP client for an individual EdgeConnect appliance.""" + + def __init__(self, http: RequestsWrapper, app_ip: str, logger: CheckLoggingAdapter) -> None: + super().__init__(http, f'https://{app_ip}') + self._app_ip = app_ip + self._log = logger + + @property + def app_ip(self) -> str: + return self._app_ip + + def _do_login(self, username: str, password: str) -> None: + resp = self._http.post( + f'{self._base_url}/rest/json/login', + json={'user': username, 'password': password}, + ) + resp.raise_for_status() + csrf_token = self._http.session.cookies.get('edgeosCsrfToken') + if csrf_token: + self._http.session.headers.update({'X-XSRF-TOKEN': csrf_token}) + else: + session_id = self._http.session.cookies.get('vxoaSessionID') + if session_id: + self._http.session.headers.update({'vxoaSessionID': session_id}) + + def get_newest_timestamp(self) -> int: + resp = self._request('get', '/rest/json/stats/minuteRange') + payload = resp.json() + newest = payload.get('newest') + if newest is None: + raise ValueError( + f"Missing 'newest' field in minuteRange response from appliance {self._app_ip}: {payload!r}" + ) + return int(newest) + + def get_minute_stats(self, filename: str) -> bytes: + resp = self._request('get', f'/rest/json/stats/minuteStats/{filename}') + return resp.content + + def get_network_interfaces(self) -> dict[str, Any]: + resp = self._request('get', '/rest/json/networkInterfaces') + return resp.json() + + def get_cpu_stats(self, timestamp: int) -> dict[str, Any] | None: + # 403 here means the user lacks admin permission, not an expired session, so we don't retry. + resp = self._request('get', f'/rest/json/cpustat?time={timestamp}', raise_on_error=False) + if resp.status_code == 403: + self._log.warning("403 fetching CPU stats from %s, no admin permissions", self._app_ip) + return None + resp.raise_for_status() + return resp.json() + + def get_memory_stats(self) -> dict[str, Any]: + resp = self._request('get', '/rest/json/memory') + return resp.json() + + def get_disk_usage(self) -> dict[str, Any]: + resp = self._request('get', '/rest/json/diskUsage') + return resp.json() + + def get_alarms(self) -> dict[str, Any]: + resp = self._request('get', '/rest/json/alarm') + return resp.json() + + def get_system_info(self) -> dict[str, Any]: + resp = self._request('get', '/rest/json/systemInfo') + return resp.json() + + def get_interface_labels(self) -> dict[str, Any]: + resp = self._request('get', '/rest/json/interfaceLabels') + return resp.json() diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/__init__.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/__init__.py new file mode 100644 index 0000000000000..f678b7e73d91a --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/__init__.py @@ -0,0 +1,24 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + +from .instance import InstanceConfig +from .shared import SharedConfig + + +class ConfigMixin: + _config_model_instance: InstanceConfig + _config_model_shared: SharedConfig + + @property + def config(self) -> InstanceConfig: + return self._config_model_instance + + @property + def shared_config(self) -> SharedConfig: + return self._config_model_shared diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/defaults.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/defaults.py new file mode 100644 index 0000000000000..f411d0b8b8a87 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/defaults.py @@ -0,0 +1,100 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + + +def instance_allow_redirects(): + return True + + +def instance_auth_type(): + return 'basic' + + +def instance_collect_events(): + return False + + +def instance_disable_generic_tags(): + return False + + +def instance_empty_default_hostname(): + return False + + +def instance_enable_legacy_tags_normalization(): + return True + + +def instance_kerberos_auth(): + return 'disabled' + + +def instance_kerberos_delegate(): + return False + + +def instance_kerberos_force_initiate(): + return False + + +def instance_log_requests(): + return False + + +def instance_max_backfill_minutes(): + return 5 + + +def instance_max_concurrency(): + return 50 + + +def instance_min_collection_interval(): + return 60 + + +def instance_namespace(): + return 'default' + + +def instance_persist_connections(): + return True + + +def instance_request_size(): + return 16 + + +def instance_send_ndm_metadata(): + return False + + +def instance_skip_proxy(): + return False + + +def instance_timeout(): + return 10 + + +def instance_tls_ignore_warning(): + return False + + +def instance_tls_use_host_header(): + return False + + +def instance_tls_verify(): + return True + + +def instance_use_legacy_auth_encoding(): + return True diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/instance.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/instance.py new file mode 100644 index 0000000000000..40131b0596d26 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/instance.py @@ -0,0 +1,157 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + +from __future__ import annotations + +from types import MappingProxyType +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from typing_extensions import Literal + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +SECURE_FIELD_NAMES = frozenset( + ['auth_token', 'kerberos_cache', 'kerberos_keytab', 'tls_ca_cert', 'tls_cert', 'tls_private_key'] +) + + +class ApplianceCredentialsOverride(BaseModel): + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True, + ) + cidr: str + password: str + username: str + + +class ApplianceIps(BaseModel): + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True, + ) + exclude: Optional[tuple[str, ...]] = None + include: Optional[tuple[str, ...]] = None + + +class AuthToken(BaseModel): + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True, + ) + reader: Optional[MappingProxyType[str, Any]] = None + writer: Optional[MappingProxyType[str, Any]] = None + + +class MetricPatterns(BaseModel): + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True, + ) + exclude: Optional[tuple[str, ...]] = None + include: Optional[tuple[str, ...]] = None + + +class Proxy(BaseModel): + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True, + ) + http: Optional[str] = None + https: Optional[str] = None + no_proxy: Optional[tuple[str, ...]] = None + + +class InstanceConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + allow_redirects: Optional[bool] = None + appliance_credentials_overrides: Optional[tuple[ApplianceCredentialsOverride, ...]] = None + appliance_ips: Optional[ApplianceIps] = None + auth_token: Optional[AuthToken] = None + auth_type: Optional[str] = None + aws_host: Optional[str] = None + aws_region: Optional[str] = None + aws_service: Optional[str] = None + collect_events: Optional[bool] = None + connect_timeout: Optional[float] = None + disable_generic_tags: Optional[bool] = None + empty_default_hostname: Optional[bool] = None + enable_legacy_tags_normalization: Optional[bool] = None + extra_headers: Optional[MappingProxyType[str, Any]] = None + headers: Optional[MappingProxyType[str, Any]] = None + kerberos_auth: Optional[Literal['required', 'optional', 'disabled']] = None + kerberos_cache: Optional[str] = None + kerberos_delegate: Optional[bool] = None + kerberos_force_initiate: Optional[bool] = None + kerberos_hostname: Optional[str] = None + kerberos_keytab: Optional[str] = None + kerberos_principal: Optional[str] = None + log_requests: Optional[bool] = None + max_backfill_minutes: Optional[int] = Field(None, ge=1) + max_concurrency: Optional[int] = Field(None, ge=1) + metric_patterns: Optional[MetricPatterns] = None + min_collection_interval: Optional[float] = None + namespace: Optional[str] = None + ntlm_domain: Optional[str] = None + orchestrator_ip: str + orchestrator_password: str + orchestrator_username: str + password: Optional[str] = None + persist_connections: Optional[bool] = None + proxy: Optional[Proxy] = None + read_timeout: Optional[float] = None + request_size: Optional[float] = None + send_ndm_metadata: Optional[bool] = None + service: Optional[str] = None + skip_proxy: Optional[bool] = None + tags: Optional[tuple[str, ...]] = None + timeout: Optional[float] = None + tls_ca_cert: Optional[str] = None + tls_cert: Optional[str] = None + tls_ciphers: Optional[tuple[str, ...]] = None + tls_ignore_warning: Optional[bool] = None + tls_private_key: Optional[str] = None + tls_protocols_allowed: Optional[tuple[str, ...]] = None + tls_use_host_header: Optional[bool] = None + tls_verify: Optional[bool] = None + use_legacy_auth_encoding: Optional[bool] = None + username: Optional[str] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_instance', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'instance_{info.field_name}', identity)(value, field=field) + + if info.field_name in SECURE_FIELD_NAMES: + validation.security.check_field_trusted_provider( + info.field_name, value, info.context.get('security_config') + ) + else: + value = getattr(defaults, f'instance_{info.field_name}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_instance', identity)(model)) diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/shared.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/shared.py new file mode 100644 index 0000000000000..10cab800f6c1e --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/shared.py @@ -0,0 +1,45 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import validators + + +class SharedConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + service: Optional[str] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_shared', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'shared_{info.field_name}', identity)(value, field=field) + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_shared', identity)(model)) diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/validators.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/validators.py new file mode 100644 index 0000000000000..6f1976ab3db70 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/config_models/validators.py @@ -0,0 +1,36 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +import ipaddress + +# Here you can include additional config validators or transformers +# +# def initialize_instance(values, **kwargs): +# if 'my_option' not in values and 'my_legacy_option' in values: +# values['my_option'] = values['my_legacy_option'] +# if values.get('my_number') > 10: +# raise ValueError('my_number max value is 10, got %s' % str(values.get('my_number'))) +# +# return values + + +def instance_appliance_ips(value, **kwargs): + if not value: + return value + + for field in ('include', 'exclude'): + for pattern in value.get(field) or (): + _validate_ip_pattern(pattern) + + return value + + +def _validate_ip_pattern(pattern: str) -> None: + try: + if '/' in pattern: + ipaddress.ip_network(pattern, strict=False) + else: + ipaddress.ip_address(pattern) + except ValueError: + raise ValueError(f'Invalid appliance_ips pattern: {pattern}') diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/constants.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/constants.py new file mode 100644 index 0000000000000..dec6ba420f48e --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/constants.py @@ -0,0 +1,77 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +MINUTE_STATS_INTERVAL = 60 + +CPU_STATE_FIELDS = { + 'user': 'pUser', + 'system': 'pSys', + 'irq': 'pIRQ', + 'nice': 'pNice', +} + +# tunnel_availability_v2.txt column indices (0-based, no header) +TUNNEL_AVAIL_COL_TUNNEL_ID = 1 +TUNNEL_AVAIL_COL_ALIAS = 2 +TUNNEL_AVAIL_COL_SECONDS_DOWN = 8 +TUNNEL_AVAIL_COL_COLOR = 13 + +# probe_v2.txt column indices (0-based, no header) +PROBE_COL_PROBE_NAME = 2 +PROBE_COL_AVG_LATENCY = 8 +PROBE_COL_AVG_LOSS = 10 +PROBE_COL_AVG_JITTER = 12 +PROBE_COL_ADMIN_UP = 13 +PROBE_COL_OPER_UP = 14 + +# appperf_v2.txt column indices (0-based, no header) +APPPERF_COL_APP_NAME = 2 +APPPERF_COL_TUNNEL_NAME = 3 +APPPERF_COL_TRANSPORT_TYPE = 4 +APPPERF_COL_CND_DELAY = 5 +APPPERF_COL_SND_DELAY = 6 +APPPERF_COL_APP_DELAY = 8 + +# tunnel_v2.txt column indices (0-based, no header) +TUNNEL_V2_COL_TUNNEL_ID = 1 +TUNNEL_V2_COL_ALIAS = 2 +TUNNEL_V2_COL_OVERLAY_ID = 3 +TUNNEL_V2_COL_IS_SDWAN = 4 +TUNNEL_V2_COL_BYTES_WAN_TX = 6 +TUNNEL_V2_COL_BYTES_WAN_RX = 7 +TUNNEL_V2_COL_BYTES_LAN_TX = 8 +TUNNEL_V2_COL_BYTES_LAN_RX = 9 +TUNNEL_V2_COL_PKTS_WAN_TX = 10 +TUNNEL_V2_COL_PKTS_WAN_RX = 11 +TUNNEL_V2_COL_PKTS_LAN_TX = 12 +TUNNEL_V2_COL_PKTS_LAN_RX = 13 +TUNNEL_V2_COL_LATENCY_AVG = 14 +TUNNEL_V2_COL_LATENCY_MIN = 15 +TUNNEL_V2_COL_LOSS_PCT_PREFEC = 23 +TUNNEL_V2_COL_LOSS_PCT_POSTFEC = 24 + +# Tunnel type value for internet breakout +TUNNEL_TYPE_INTERNET_BREAKOUT = ( + 2 # https://github.com/aruba/pyedgeconnect/blob/main/pyedgeconnect/orch/_timeseries_stats.py#L1913 +) + +# NDM resource tag prefixes (used by the backend to correlate metrics with NDM entities) +NDM_DEVICE_RESOURCE_TAG = 'dd.internal.resource:ndm_device' +NDM_DEVICE_USER_TAGS_RESOURCE_TAG = 'dd.internal.resource:ndm_device_user_tags' +NDM_INTERFACE_RESOURCE_TAG = 'dd.internal.resource:ndm_interface' + +ALARM_SEVERITY_BY_ID = { + 1: 'warning', + 2: 'minor', + 3: 'major', + 4: 'critical', +} + +ALARM_SEVERITY_TO_ALERT_TYPE = { + 'critical': 'error', + 'major': 'error', + 'minor': 'warning', + 'warning': 'warning', +} diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/data/conf.yaml.example b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/data/conf.yaml.example new file mode 100644 index 0000000000000..9ec1b981626cd --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/data/conf.yaml.example @@ -0,0 +1,470 @@ +## All options defined here are available to all instances. +# +init_config: + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Additionally, this sets the default `service` for every log source. + # + # service: + +## Every instance is scheduled independently of the others. +# +instances: + + ## @param orchestrator_ip - string - required + ## Hostname or IP address of the HPE Aruba EdgeConnect Orchestrator. + # + - orchestrator_ip: 10.0.0.1 + + ## @param orchestrator_username - string - required + ## Username for the Orchestrator. If no appliance-specific credentials are provided, + ## this username is used as the default for appliances. + # + orchestrator_username: + + ## @param orchestrator_password - string - required + ## Password for the Orchestrator. If no appliance-specific credentials are provided, + ## this password is used as the default for appliances. + # + orchestrator_password: + + ## @param namespace - string - optional - default: default + ## Namespace to use for NDM device metadata. Defaults to "default". + # + # namespace: default + + ## @param appliance_ips - mapping - optional + ## HPE Aruba EdgeConnect appliance IP filters. CIDR entries match any appliance IP within the block. + ## If no filters are configured, all discovered appliances are included. + ## Use `include` to specify which appliances to poll and `exclude` to skip individual appliances. + ## `exclude` takes precedence over `include`. + # + # appliance_ips: + # include: + # - 10.0.0.0/24 + # - 192.168.1.5 + # exclude: + # - 10.0.0.99 + + ## @param max_concurrency - integer - optional - default: 50 + ## Maximum number of appliances to poll concurrently. + # + # max_concurrency: 50 + + ## @param max_backfill_minutes - integer - optional - default: 5 + ## How many minutes of historical data to recover for each appliance after the Agent has been + ## offline. If the Agent stops collecting for a while (for example, during a restart or an + ## outage), this controls how far back in time it looks when it starts up again. + ## + ## For example, with the default of 5, the Agent collects at most the last 5 minutes + ## of data per appliance after coming back online. Anything older than that is skipped, and + ## collection resumes normally from the most recent point. + # + # max_backfill_minutes: 5 + + ## @param appliance_credentials_overrides - list of mappings - optional + ## Per-appliance credential overrides matched by CIDR. When an appliance IP falls within a listed + ## CIDR block, the associated username and password are used instead of the main credentials. + ## If multiple CIDR blocks match, the Agent uses the first match. Each entry must specify all three + ## fields: `cidr`, `username`, and `password`. + # + # appliance_credentials_overrides: + # - cidr: 10.0.0.0/24 + # username: admin + # password: secret + + ## @param proxy - mapping - optional + ## This overrides the `proxy` setting in `init_config`. + ## + ## Set HTTP or HTTPS proxies for this instance. Use the `no_proxy` list + ## to specify hosts that must bypass proxies. + ## + ## The SOCKS protocol is also supported, for example: + ## + ## socks5://user:pass@host:port + ## + ## Using the scheme `socks5` causes the DNS resolution to happen on the + ## client, rather than on the proxy server. This is in line with `curl`, + ## which uses the scheme to decide whether to do the DNS resolution on + ## the client or proxy. If you want to resolve the domains on the proxy + ## server, use `socks5h` as the scheme. + # + # proxy: + # http: http://: + # https: https://: + # no_proxy: + # - + # - + + ## @param skip_proxy - boolean - optional - default: false + ## This overrides the `skip_proxy` setting in `init_config`. + ## + ## If set to `true`, this makes the check bypass any proxy + ## settings enabled and attempt to reach services directly. + # + # skip_proxy: false + + ## @param auth_type - string - optional - default: basic + ## The type of authentication to use. The available types (and related options) are: + ## ``` + ## - basic + ## |__ username + ## |__ password + ## |__ use_legacy_auth_encoding + ## - digest + ## |__ username + ## |__ password + ## - ntlm + ## |__ ntlm_domain + ## |__ password + ## - kerberos + ## |__ kerberos_auth + ## |__ kerberos_cache + ## |__ kerberos_delegate + ## |__ kerberos_force_initiate + ## |__ kerberos_hostname + ## |__ kerberos_keytab + ## |__ kerberos_principal + ## - aws + ## |__ aws_region + ## |__ aws_host + ## |__ aws_service + ## ``` + ## The `aws` auth type relies on boto3 to automatically gather AWS credentials, for example: from `.aws/credentials`. + ## Details: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html#configuring-credentials + # + # auth_type: basic + + ## @param use_legacy_auth_encoding - boolean - optional - default: true + ## When `auth_type` is set to `basic`, this determines whether to encode as `latin1` rather than `utf-8`. + # + # use_legacy_auth_encoding: true + + ## @param username - string - optional + ## The username to use if services are behind basic or digest auth. + # + # username: + + ## @param password - string - optional + ## The password to use if services are behind basic or NTLM auth. + # + # password: + + ## @param ntlm_domain - string - optional + ## If your services use NTLM authentication, specify + ## the domain used in the check. For NTLM Auth, append + ## the username to domain, not as the `username` parameter. + # + # ntlm_domain: \ + + ## @param kerberos_auth - string - optional - default: disabled + ## If your services use Kerberos authentication, you can specify the Kerberos + ## strategy to use between: + ## + ## - required + ## - optional + ## - disabled + ## + ## See https://github.com/requests/requests-kerberos#mutual-authentication + # + # kerberos_auth: disabled + + ## @param kerberos_cache - string - optional + ## Sets the KRB5CCNAME environment variable. + ## It should point to a credential cache with a valid TGT. + # + # kerberos_cache: + + ## @param kerberos_delegate - boolean - optional - default: false + ## Set to `true` to enable Kerberos delegation of credentials to a server that requests delegation. + ## + ## See https://github.com/requests/requests-kerberos#delegation + # + # kerberos_delegate: false + + ## @param kerberos_force_initiate - boolean - optional - default: false + ## Set to `true` to preemptively initiate the Kerberos GSS exchange and + ## present a Kerberos ticket on the initial request (and all subsequent). + ## + ## See https://github.com/requests/requests-kerberos#preemptive-authentication + # + # kerberos_force_initiate: false + + ## @param kerberos_hostname - string - optional + ## Override the hostname used for the Kerberos GSS exchange if its DNS name doesn't + ## match its Kerberos hostname, for example: behind a content switch or load balancer. + ## + ## See https://github.com/requests/requests-kerberos#hostname-override + # + # kerberos_hostname: + + ## @param kerberos_principal - string - optional + ## Set an explicit principal, to force Kerberos to look for a + ## matching credential cache for the named user. + ## + ## See https://github.com/requests/requests-kerberos#explicit-principal + # + # kerberos_principal: + + ## @param kerberos_keytab - string - optional + ## Set the path to your Kerberos key tab file. + # + # kerberos_keytab: + + ## @param auth_token - mapping - optional + ## This allows for the use of authentication information from dynamic sources. + ## Both a reader and writer must be configured. + ## + ## The available readers are: + ## + ## - type: file + ## path (required): The absolute path for the file to read from. + ## pattern: A regular expression pattern with a single capture group used to find the + ## token rather than using the entire file, for example: Your secret is (.+) + ## - type: oauth + ## url (required): The token endpoint. + ## client_id (required): The client identifier. + ## client_secret (required): The client secret. + ## basic_auth: Whether the provider expects credentials to be transmitted in + ## an HTTP Basic Auth header. The default is: false + ## options: Mapping of additional options to pass to the provider, such as the audience + ## or the scope. For example: + ## options: + ## audience: https://example.com + ## scope: read:example + ## + ## The available writers are: + ## + ## - type: header + ## name (required): The name of the field, for example: Authorization + ## value: The template value, for example `Bearer `. The default is: + ## placeholder: The substring in `value` to replace with the token, defaults to: + # + # auth_token: + # reader: + # type: + # : + # : + # writer: + # type: + # : + # : + + ## @param aws_region - string - optional + ## If your services require AWS Signature Version 4 signing, set the region. + ## + ## See https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html + # + # aws_region: + + ## @param aws_host - string - optional + ## If your services require AWS Signature Version 4 signing, set the host. + ## This only needs the hostname and does not require the protocol (HTTP, HTTPS, and more). + ## For example, if connecting to https://us-east-1.amazonaws.com/, set `aws_host` to `us-east-1.amazonaws.com`. + ## + ## Note: This setting is not necessary for official integrations. + ## + ## See https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html + # + # aws_host: + + ## @param aws_service - string - optional + ## If your services require AWS Signature Version 4 signing, set the service code. For a list + ## of available service codes, see https://docs.aws.amazon.com/general/latest/gr/rande.html + ## + ## Note: This setting is not necessary for official integrations. + ## + ## See https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html + # + # aws_service: + + ## @param tls_verify - boolean - optional - default: true + ## Instructs the check to validate the TLS certificate of services. + # + # tls_verify: true + + ## @param tls_use_host_header - boolean - optional - default: false + ## If a `Host` header is set, this enables its use for SNI (matching against the TLS certificate CN or SAN). + # + # tls_use_host_header: false + + ## @param tls_ignore_warning - boolean - optional - default: false + ## If `tls_verify` is disabled, security warnings are logged by the check. + ## Disable those by setting `tls_ignore_warning` to true. + # + # tls_ignore_warning: false + + ## @param tls_cert - string - optional + ## The path to a single file in PEM format containing a certificate as well as any + ## number of CA certificates needed to establish the certificate's authenticity for + ## use when connecting to services. It may also contain an unencrypted private key to use. + # + # tls_cert: + + ## @param tls_private_key - string - optional + ## The unencrypted private key to use for `tls_cert` when connecting to services. This is + ## required if `tls_cert` is set and it does not already contain a private key. + # + # tls_private_key: + + ## @param tls_ca_cert - string - optional + ## The path to a file of concatenated CA certificates in PEM format or a directory + ## containing several CA certificates in PEM format. If a directory, the directory + ## must have been processed using the `openssl rehash` command. See: + ## https://www.openssl.org/docs/man3.2/man1/c_rehash.html + # + # tls_ca_cert: + + ## @param tls_protocols_allowed - list of strings - optional + ## The expected versions of TLS/SSL when fetching intermediate certificates. + ## Only `SSLv3`, `TLSv1.2`, `TLSv1.3` are allowed by default. The possible values are: + ## SSLv3 + ## TLSv1 + ## TLSv1.1 + ## TLSv1.2 + ## TLSv1.3 + # + # tls_protocols_allowed: + # - SSLv3 + # - TLSv1.2 + # - TLSv1.3 + + ## @param tls_ciphers - list of strings - optional + ## The list of ciphers suites to use when connecting to an endpoint. If not specified, + ## `ALL` ciphers are used. For list of ciphers see: + ## https://www.openssl.org/docs/man1.0.2/man1/ciphers.html + # + # tls_ciphers: + # - TLS_AES_256_GCM_SHA384 + # - TLS_CHACHA20_POLY1305_SHA256 + # - TLS_AES_128_GCM_SHA256 + + ## @param headers - mapping - optional + ## The headers parameter allows you to send specific headers with every request. + ## You can use it for explicitly specifying the host header or adding headers for + ## authorization purposes. + ## + ## This overrides any default headers. + # + # headers: + # Host: + # X-Auth-Token: + + ## @param extra_headers - mapping - optional + ## Additional headers to send with every request. + # + # extra_headers: + # Host: + # X-Auth-Token: + + ## @param timeout - number - optional - default: 10 + ## The timeout for accessing services. + ## + ## This overrides the `timeout` setting in `init_config`. + # + # timeout: 10 + + ## @param connect_timeout - number - optional + ## The connect timeout for accessing services. Defaults to `timeout`. + # + # connect_timeout: + + ## @param read_timeout - number - optional + ## The read timeout for accessing services. Defaults to `timeout`. + # + # read_timeout: + + ## @param request_size - number - optional - default: 16 + ## The number of kibibytes (KiB) to read from streaming HTTP responses at a time. + # + # request_size: 16 + + ## @param log_requests - boolean - optional - default: false + ## Whether or not to debug log the HTTP(S) requests made, including the method and URL. + # + # log_requests: false + + ## @param persist_connections - boolean - optional - default: true + ## Connections are always persisted to use cookies and connection pooling for improved performance. + # + # persist_connections: true + + ## @param allow_redirects - boolean - optional - default: true + ## Whether or not to allow URL redirection. + # + # allow_redirects: true + + ## @param tags - list of strings - optional + ## A list of tags to attach to every metric and service check emitted by this instance. + ## + ## Learn more about tagging at https://docs.datadoghq.com/tagging + # + # tags: + # - : + # - : + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Overrides any `service` defined in the `init_config` section. + # + # service: + + ## @param min_collection_interval - number - optional - default: 60 + ## Changes the collection interval of the check. For more information, see + ## https://docs.datadoghq.com/developers/write_agent_check/#collection-interval. + ## The default is 60 seconds. Statistics are only available at one-minute intervals, + ## so more frequent runs do not collect additional data. + # + # min_collection_interval: 60 + + ## @param empty_default_hostname - boolean - optional - default: false + ## This forces the check to send metrics with no hostname. + ## + ## This is useful for cluster-level checks. + # + # empty_default_hostname: false + + ## @param metric_patterns - mapping - optional + ## A mapping of metrics to include or exclude, with each entry being a regular expression. + ## + ## Metrics defined in `exclude` will take precedence in case of overlap. + # + # metric_patterns: + # include: + # - + # exclude: + # - + + ## @param send_ndm_metadata - boolean - optional - default: false + ## Set to `true` to enable Network Device Monitoring metadata (for devices, interfaces, topology) to be sent. + # + # send_ndm_metadata: false + + ## @param collect_events - boolean - optional - default: false + ## Set to `true` to enable collection of HPE Aruba EdgeConnect alarm events. + # + # collect_events: false + +## Log Section +## +## type - required - Type of log input source (tcp / udp / file / windows_event). +## port / path / channel_path - required - Set port if type is tcp or udp. +## Set path if type is file. +## Set channel_path if type is windows_event. +## source - required - Attribute that defines which integration sent the logs. +## encoding - optional - For file specifies the file encoding. Default is utf-8. Other +## possible values are utf-16-le and utf-16-be. +## service - optional - The name of the service that generates the log. +## Overrides any `service` defined in the `init_config` section. +## tags - optional - Add tags to the collected logs. +## +## Discover Datadog log collection: https://docs.datadoghq.com/logs/log_collection/ +# +# logs: +# - type: tcp +# port: 10514 +# service: hpe_aruba_edgeconnect +# source: hpe_aruba_edgeconnect diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/metrics_store.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/metrics_store.py new file mode 100644 index 0000000000000..196f9cf968f6d --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/metrics_store.py @@ -0,0 +1,55 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datadog_checks.base import AgentCheck + + +class AggType(Enum): + SUM = 'sum' + AVG = 'avg' + MAX = 'max' + MIN = 'min' + LAST = 'last' + + +class MetricsStore: + """Accumulates per-interval metric samples and flushes aggregated values.""" + + def __init__(self) -> None: + self._metrics: dict[tuple[str, tuple[str, ...]], tuple[AggType, list[float]]] = {} + + def record(self, name: str, value: float | None, tags: list[str], agg_type: AggType) -> None: + if value is None: + return + key = (name, tuple(sorted(tags))) + entry = self._metrics.get(key) + if entry is None: + self._metrics[key] = (agg_type, [value]) + else: + entry[1].append(value) + + def flush(self, check: AgentCheck) -> None: + for (name, tags), (agg_type, values) in self._metrics.items(): + check.gauge(name, _aggregate(agg_type, values), tags=list(tags)) + + +def _aggregate(agg_type: AggType, values: list[float]) -> float: + match agg_type: + case AggType.SUM: + return sum(values) + case AggType.AVG: + return sum(values) / len(values) + case AggType.MAX: + return max(values) + case AggType.MIN: + return min(values) + case AggType.LAST: + return values[-1] + case _: + raise ValueError(f"Invalid aggregation type: {agg_type}") diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/minute_stats.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/minute_stats.py new file mode 100644 index 0000000000000..e09a637d39cb1 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/minute_stats.py @@ -0,0 +1,921 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import csv +import os +import re +import tarfile +from collections.abc import Iterator +from dataclasses import dataclass +from io import BytesIO, StringIO +from typing import TYPE_CHECKING, ClassVar, Literal, Protocol + +from datadog_checks.hpe_aruba_edgeconnect.constants import ( + APPPERF_COL_APP_DELAY, + APPPERF_COL_APP_NAME, + APPPERF_COL_CND_DELAY, + APPPERF_COL_SND_DELAY, + APPPERF_COL_TRANSPORT_TYPE, + APPPERF_COL_TUNNEL_NAME, + MINUTE_STATS_INTERVAL, + NDM_INTERFACE_RESOURCE_TAG, + PROBE_COL_ADMIN_UP, + PROBE_COL_AVG_JITTER, + PROBE_COL_AVG_LATENCY, + PROBE_COL_AVG_LOSS, + PROBE_COL_OPER_UP, + PROBE_COL_PROBE_NAME, + TUNNEL_AVAIL_COL_ALIAS, + TUNNEL_AVAIL_COL_COLOR, + TUNNEL_AVAIL_COL_SECONDS_DOWN, + TUNNEL_AVAIL_COL_TUNNEL_ID, + TUNNEL_TYPE_INTERNET_BREAKOUT, + TUNNEL_V2_COL_ALIAS, + TUNNEL_V2_COL_BYTES_LAN_RX, + TUNNEL_V2_COL_BYTES_LAN_TX, + TUNNEL_V2_COL_BYTES_WAN_RX, + TUNNEL_V2_COL_BYTES_WAN_TX, + TUNNEL_V2_COL_IS_SDWAN, + TUNNEL_V2_COL_LATENCY_AVG, + TUNNEL_V2_COL_LATENCY_MIN, + TUNNEL_V2_COL_LOSS_PCT_POSTFEC, + TUNNEL_V2_COL_LOSS_PCT_PREFEC, + TUNNEL_V2_COL_OVERLAY_ID, + TUNNEL_V2_COL_PKTS_LAN_RX, + TUNNEL_V2_COL_PKTS_LAN_TX, + TUNNEL_V2_COL_PKTS_WAN_RX, + TUNNEL_V2_COL_PKTS_WAN_TX, + TUNNEL_V2_COL_TUNNEL_ID, +) +from datadog_checks.hpe_aruba_edgeconnect.metrics_store import AggType, MetricsStore + +if TYPE_CHECKING: + from datadog_checks.base.log import CheckLoggingAdapter + + +TUNNEL_ALIAS_WITH_LABELS_RE = re.compile(r'^to_(.+)_(\w+-\w+)$') +TUNNEL_ALIAS_RE = re.compile(r'^to_(.+)_\w+$') +NON_PEER_ALIAS_RE = re.compile(r'^\w+_(\w+)_.+$') + + +TUNNEL_AGGREGATE_ALIASES = frozenset({'all traffic', 'optimized traffic', 'pass-through', 'pass-through-unshaped'}) + + +def _nonzero(raw: str | None) -> bool: + if raw is None: + return False + try: + return float(raw) != 0 + except ValueError: + return False + + +def _get_float(raw: str | None, divisor: float = 1.0) -> float | None: + if raw is None: + return None + try: + return float(raw) / divisor + except (TypeError, ValueError): + return None + + +def parse_tunnel_alias(alias: str, wan_labels: set[str] | None = None) -> tuple[str, str]: + """Returns (peer_hostname, tunnel_color) parsed from a tunnel alias.""" + m = TUNNEL_ALIAS_WITH_LABELS_RE.match(alias) + if m: + return m.group(1), m.group(2) + m = TUNNEL_ALIAS_RE.match(alias) + if m: + return m.group(1), '' + if wan_labels: + m = NON_PEER_ALIAS_RE.match(alias) + if m and m.group(1) in wan_labels: + return '', m.group(1) + return '', '' + + +@dataclass(init=False, slots=True) +class TunnelV2Stats: + tunnel_id: str + tunnel_alias: str + overlay_id: str + is_sdwan: Literal['true', 'false', ''] + bytes_wan_tx: float | None + bytes_wan_rx: float | None + bytes_lan_tx: float | None + bytes_lan_rx: float | None + pkts_wan_tx: float | None + pkts_wan_rx: float | None + pkts_lan_tx: float | None + pkts_lan_rx: float | None + latency: float | None + latency_min: float | None + loss_prefec: float | None + loss_postfec: float | None + + def __init__(self, cols: list[str]) -> None: + n = len(cols) + self.tunnel_id = cols[TUNNEL_V2_COL_TUNNEL_ID] if n > TUNNEL_V2_COL_TUNNEL_ID else '' + self.tunnel_alias = cols[TUNNEL_V2_COL_ALIAS] if n > TUNNEL_V2_COL_ALIAS else '' + self.overlay_id = cols[TUNNEL_V2_COL_OVERLAY_ID] if n > TUNNEL_V2_COL_OVERLAY_ID else '' + if n > TUNNEL_V2_COL_IS_SDWAN: + self.is_sdwan = 'true' if cols[TUNNEL_V2_COL_IS_SDWAN] == '1' else 'false' + else: + self.is_sdwan = '' + self.bytes_wan_tx = _get_float(cols[TUNNEL_V2_COL_BYTES_WAN_TX]) if n > TUNNEL_V2_COL_BYTES_WAN_TX else None + self.bytes_wan_rx = _get_float(cols[TUNNEL_V2_COL_BYTES_WAN_RX]) if n > TUNNEL_V2_COL_BYTES_WAN_RX else None + self.bytes_lan_tx = _get_float(cols[TUNNEL_V2_COL_BYTES_LAN_TX]) if n > TUNNEL_V2_COL_BYTES_LAN_TX else None + self.bytes_lan_rx = _get_float(cols[TUNNEL_V2_COL_BYTES_LAN_RX]) if n > TUNNEL_V2_COL_BYTES_LAN_RX else None + self.pkts_wan_tx = _get_float(cols[TUNNEL_V2_COL_PKTS_WAN_TX]) if n > TUNNEL_V2_COL_PKTS_WAN_TX else None + self.pkts_wan_rx = _get_float(cols[TUNNEL_V2_COL_PKTS_WAN_RX]) if n > TUNNEL_V2_COL_PKTS_WAN_RX else None + self.pkts_lan_tx = _get_float(cols[TUNNEL_V2_COL_PKTS_LAN_TX]) if n > TUNNEL_V2_COL_PKTS_LAN_TX else None + self.pkts_lan_rx = _get_float(cols[TUNNEL_V2_COL_PKTS_LAN_RX]) if n > TUNNEL_V2_COL_PKTS_LAN_RX else None + self.latency = _get_float(cols[TUNNEL_V2_COL_LATENCY_AVG], 100) if n > TUNNEL_V2_COL_LATENCY_AVG else None + self.latency_min = _get_float(cols[TUNNEL_V2_COL_LATENCY_MIN], 100) if n > TUNNEL_V2_COL_LATENCY_MIN else None + self.loss_prefec = ( + _get_float(cols[TUNNEL_V2_COL_LOSS_PCT_PREFEC], 100) if n > TUNNEL_V2_COL_LOSS_PCT_PREFEC else None + ) + self.loss_postfec = ( + _get_float(cols[TUNNEL_V2_COL_LOSS_PCT_POSTFEC], 100) if n > TUNNEL_V2_COL_LOSS_PCT_POSTFEC else None + ) + + def record(self, store: MetricsStore, base_tags: list[str], overlay_map: dict[str, str] | None = None) -> list[str]: + """Records tunnel metrics and returns the extra tags for cross-referencing.""" + extra_tags = [f'tunnel_alias:{self.tunnel_alias}', f'is_sdwan:{self.is_sdwan}'] + if overlay_map: + overlay_name = overlay_map.get(self.overlay_id) + if overlay_name: + extra_tags.append(f'overlay_name:{overlay_name}') + base_tunnel_tags = base_tags + [f'tunnel_name:{self.tunnel_id}'] + extra_tags + for side, bytes_tx, bytes_rx, pkts_tx, pkts_rx in [ + ('wan', self.bytes_wan_tx, self.bytes_wan_rx, self.pkts_wan_tx, self.pkts_wan_rx), + ('lan', self.bytes_lan_tx, self.bytes_lan_rx, self.pkts_lan_tx, self.pkts_lan_rx), + ]: + tags = base_tunnel_tags + [f'side:{side}'] + store.record('tunnel.throughput.tx.bytes.count', bytes_tx, tags, AggType.SUM) + store.record('tunnel.throughput.rx.bytes.count', bytes_rx, tags, AggType.SUM) + store.record('tunnel.throughput.tx.packets.count', pkts_tx, tags, AggType.SUM) + store.record('tunnel.throughput.rx.packets.count', pkts_rx, tags, AggType.SUM) + if bytes_tx is not None: + store.record('tunnel.throughput.tx.bytes.rate', bytes_tx / MINUTE_STATS_INTERVAL, tags, AggType.AVG) + if bytes_rx is not None: + store.record('tunnel.throughput.rx.bytes.rate', bytes_rx / MINUTE_STATS_INTERVAL, tags, AggType.AVG) + if pkts_tx is not None: + store.record('tunnel.throughput.tx.packets.rate', pkts_tx / MINUTE_STATS_INTERVAL, tags, AggType.AVG) + if pkts_rx is not None: + store.record('tunnel.throughput.rx.packets.rate', pkts_rx / MINUTE_STATS_INTERVAL, tags, AggType.AVG) + store.record('tunnel.latency', self.latency, base_tunnel_tags, AggType.AVG) + store.record('tunnel.latency.min', self.latency_min, base_tunnel_tags, AggType.MIN) + store.record('tunnel.loss', self.loss_postfec, base_tunnel_tags + ['fec:post'], AggType.AVG) + store.record('tunnel.loss', self.loss_prefec, base_tunnel_tags + ['fec:pre'], AggType.AVG) + return extra_tags + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[TunnelV2Stats]: + for line in content.splitlines(): + line = line.strip() + if not line: + continue + stat = cls(line.split(',')) + if stat.tunnel_id in TUNNEL_AGGREGATE_ALIASES and stat.tunnel_id == stat.tunnel_alias: + continue + yield stat + + +@dataclass(init=False, slots=True) +class TunnelPeakStats: + tunname: str + peak_bytes_wan_tx: float | None + peak_bytes_wan_rx: float | None + peak_bytes_lan_tx: float | None + peak_bytes_lan_rx: float | None + peak_pkts_wan_tx: float | None + peak_pkts_wan_rx: float | None + peak_pkts_lan_tx: float | None + peak_pkts_lan_rx: float | None + peak_latency: float | None + + def __init__(self, row: dict[str, str]) -> None: + self.tunname = v.strip() if (v := row.get('tunname')) is not None else "" + self.peak_bytes_wan_tx = _get_float(row.get('bytes_wtx')) + self.peak_bytes_wan_rx = _get_float(row.get('bytes_wrx')) + self.peak_bytes_lan_tx = _get_float(row.get('bytes_ltx')) + self.peak_bytes_lan_rx = _get_float(row.get('bytes_lrx')) + self.peak_pkts_wan_tx = _get_float(row.get('pkts_wtx')) + self.peak_pkts_wan_rx = _get_float(row.get('pkts_wrx')) + self.peak_pkts_lan_tx = _get_float(row.get('pkts_ltx')) + self.peak_pkts_lan_rx = _get_float(row.get('pkts_lrx')) + self.peak_latency = _get_float(row.get('latency_s'), 100) + + def record(self, store: MetricsStore, base_tags: list[str], extra_tags: list[str]) -> None: + base_tunnel_tags = base_tags + [f'tunnel_name:{self.tunname}'] + extra_tags + for side, bytes_tx, bytes_rx, pkts_tx, pkts_rx in [ + ('wan', self.peak_bytes_wan_tx, self.peak_bytes_wan_rx, self.peak_pkts_wan_tx, self.peak_pkts_wan_rx), + ('lan', self.peak_bytes_lan_tx, self.peak_bytes_lan_rx, self.peak_pkts_lan_tx, self.peak_pkts_lan_rx), + ]: + if bytes_tx is None and bytes_rx is None: + continue + tags = base_tunnel_tags + [f'side:{side}'] + if bytes_tx is not None: + store.record('tunnel.throughput.tx.bytes.max', bytes_tx, tags, AggType.MAX) + if bytes_rx is not None: + store.record('tunnel.throughput.rx.bytes.max', bytes_rx, tags, AggType.MAX) + if pkts_tx is not None: + store.record('tunnel.throughput.tx.packets.max', pkts_tx, tags, AggType.MAX) + if pkts_rx is not None: + store.record('tunnel.throughput.rx.packets.max', pkts_rx, tags, AggType.MAX) + if self.peak_latency is not None: + store.record('tunnel.latency.max', self.peak_latency, base_tunnel_tags, AggType.MAX) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[TunnelPeakStats]: + reader = csv.DictReader(StringIO(content)) + for row in reader: + if row.get('tunname', '').strip() in TUNNEL_AGGREGATE_ALIASES: + continue + yield cls(row) + + +@dataclass(init=False, slots=True) +class JitterStats: + tunnel: str + jitter: float | None + peak_jitter: float | None + + def __init__(self, row: dict[str, str]) -> None: + self.tunnel = v.strip() if (v := row.get('tunnel')) is not None else "" + self.jitter = _get_float(row.get(' jitter') or row.get('jitter')) + self.peak_jitter = _get_float(row.get(' peak_jitter') or row.get('peak_jitter')) + + def record(self, store: MetricsStore, tags: list[str]) -> None: + store.record('tunnel.jitter', self.jitter, tags, AggType.AVG) + store.record('tunnel.jitter.max', self.peak_jitter, tags, AggType.MAX) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[JitterStats]: + reader = csv.DictReader(StringIO(content)) + for row in reader: + yield cls(row) + + +@dataclass(init=False, slots=True) +class MosStats: + tunnel: str + mos_postfec: float | None + mos_prefec: float | None + min_mos_postfec: float | None + min_mos_prefec: float | None + + def __init__(self, row: dict[str, str]) -> None: + self.tunnel = v.strip() if (v := row.get('tunnel')) is not None else "" + self.mos_postfec = _get_float(row.get(' mos_postfec')) + self.mos_prefec = _get_float(row.get(' mos_prefec')) + self.min_mos_postfec = _get_float(row.get(' min_mos_postfec')) + self.min_mos_prefec = _get_float(row.get(' min_mos_prefec')) + + def record(self, store: MetricsStore, base_tags: list[str]) -> None: + store.record('tunnel.qoe.mos', self.mos_postfec, base_tags + ['fec:post'], AggType.AVG) + store.record('tunnel.qoe.mos', self.mos_prefec, base_tags + ['fec:pre'], AggType.AVG) + store.record('tunnel.qoe.mos.min', self.min_mos_postfec, base_tags + ['fec:post'], AggType.MIN) + store.record('tunnel.qoe.mos.min', self.min_mos_prefec, base_tags + ['fec:pre'], AggType.MIN) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[MosStats]: + reader = csv.DictReader(StringIO(content)) + for row in reader: + yield cls(row) + + +@dataclass(init=False, slots=True) +class TunnelAvailability: + tunnel_id: str + alias: str + seconds_down: float | None + color: str + peer: str | None + + def __init__(self, cols: list[str]) -> None: + n = len(cols) + self.tunnel_id = cols[TUNNEL_AVAIL_COL_TUNNEL_ID] if n > TUNNEL_AVAIL_COL_TUNNEL_ID else '' + self.alias = cols[TUNNEL_AVAIL_COL_ALIAS] if n > TUNNEL_AVAIL_COL_ALIAS else '' + self.seconds_down = ( + _get_float(cols[TUNNEL_AVAIL_COL_SECONDS_DOWN]) if n > TUNNEL_AVAIL_COL_SECONDS_DOWN else None + ) + self.color = cols[TUNNEL_AVAIL_COL_COLOR] if n > TUNNEL_AVAIL_COL_COLOR else '' + peer, _ = parse_tunnel_alias(self.alias) + self.peer = peer or None + + def record(self, store: MetricsStore, base_tags: list[str]) -> None: + tags = base_tags + [ + f'tunnel_name:{self.tunnel_id}', + f'tunnel_alias:{self.alias}', + f'tunnel_color:{self.color}', + ] + if self.peer is not None: + tags.append(f'peer:{self.peer}') + if self.seconds_down is not None: + uptime_pct = max(0.0, (MINUTE_STATS_INTERVAL - self.seconds_down) / MINUTE_STATS_INTERVAL * 100) + store.record('tunnel.availability', uptime_pct, tags, AggType.AVG) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[TunnelAvailability]: + for line in content.splitlines(): + line = line.strip() + if not line: + continue + stat = cls(line.split(',')) + if stat.tunnel_id in TUNNEL_AGGREGATE_ALIASES and stat.tunnel_id == stat.alias: + continue + yield stat + + +@dataclass(init=False, slots=True) +class InterfaceStats: + ifname: str + bytes_tx: float | None + bytes_rx: float | None + fwdrops_bytes_tx: float | None + fwdrops_bytes_rx: float | None + fwdrops_pkts_tx: float | None + fwdrops_pkts_rx: float | None + max_bw_tx: float | None + max_bw_rx: float | None + traftype: str + _log: CheckLoggingAdapter + + def __init__(self, row: dict[str, str], logger: CheckLoggingAdapter) -> None: + self.ifname = v.strip() if (v := row.get('ifname')) is not None else "" + self.bytes_tx = _get_float(row.get('bytes_tx')) + self.bytes_rx = _get_float(row.get('bytes_rx')) + self.fwdrops_bytes_tx = _get_float(row.get('fwdrops_bytes_tx')) + self.fwdrops_bytes_rx = _get_float(row.get('fwdrops_bytes_rx')) + self.fwdrops_pkts_tx = _get_float(row.get('fwdrops_pkts_tx')) + self.fwdrops_pkts_rx = _get_float(row.get('fwdrops_pkts_rx')) + self.max_bw_tx = _get_float(row.get('max_bw_tx')) + self.max_bw_rx = _get_float(row.get('max_bw_rx')) + self.traftype = v.strip() if (v := row.get('traftype')) is not None else "" + self._log = logger + + def record(self, store: MetricsStore, base_tags: list[str], device_id: str) -> None: + tags = base_tags + [ + f'interface_name:{self.ifname}', + f'traffic_type:{self.traftype}', + f'{NDM_INTERFACE_RESOURCE_TAG}:{device_id}', + ] + bw_tx = self.bytes_tx / MINUTE_STATS_INTERVAL if self.bytes_tx is not None else None + bw_rx = self.bytes_rx / MINUTE_STATS_INTERVAL if self.bytes_rx is not None else None + store.record('interface.bandwidth.tx.count', self.bytes_tx, tags, AggType.SUM) + store.record('interface.bandwidth.rx.count', self.bytes_rx, tags, AggType.SUM) + store.record('interface.bandwidth.tx.rate', bw_tx, tags, AggType.AVG) + store.record('interface.bandwidth.rx.rate', bw_rx, tags, AggType.AVG) + store.record('interface.drops.bytes.tx.count', self.fwdrops_bytes_tx, tags, AggType.SUM) + store.record('interface.drops.bytes.rx.count', self.fwdrops_bytes_rx, tags, AggType.SUM) + store.record('interface.drops.packets.tx.count', self.fwdrops_pkts_tx, tags, AggType.SUM) + store.record('interface.drops.packets.rx.count', self.fwdrops_pkts_rx, tags, AggType.SUM) + if self.fwdrops_bytes_tx is not None: + store.record( + 'interface.drops.bytes.tx.rate', self.fwdrops_bytes_tx / MINUTE_STATS_INTERVAL, tags, AggType.AVG + ) + if self.fwdrops_bytes_rx is not None: + store.record( + 'interface.drops.bytes.rx.rate', self.fwdrops_bytes_rx / MINUTE_STATS_INTERVAL, tags, AggType.AVG + ) + if self.fwdrops_pkts_tx is not None: + store.record( + 'interface.drops.packets.tx.rate', self.fwdrops_pkts_tx / MINUTE_STATS_INTERVAL, tags, AggType.AVG + ) + if self.fwdrops_pkts_rx is not None: + store.record( + 'interface.drops.packets.rx.rate', self.fwdrops_pkts_rx / MINUTE_STATS_INTERVAL, tags, AggType.AVG + ) + if not self.max_bw_tx or not self.max_bw_rx: + self._log.warning( + "Max bandwidth is not available for %s, skipping average utilization metrics", self.ifname + ) + else: + if bw_tx is not None: + store.record('interface.utilization.tx.avg', bw_tx / self.max_bw_tx * 100, tags, AggType.AVG) + if bw_rx is not None: + store.record('interface.utilization.rx.avg', bw_rx / self.max_bw_rx * 100, tags, AggType.AVG) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter) -> Iterator[InterfaceStats]: + rows = list(csv.DictReader(StringIO(content))) + # Some appliances only populate max_bw on the `all traffic` row; fall back to it when a + # per-type row has no max_bw of its own (missing or zero) so utilization stays computable. + fallback_max_bw: dict[str, tuple[str | None, str | None]] = {} + for row in rows: + if row.get('traftype', '').strip() != 'all traffic': + continue + ifname = row.get('ifname', '').strip() + if ifname: + fallback_max_bw[ifname] = (row.get('max_bw_tx'), row.get('max_bw_rx')) + for row in rows: + if row.get('traftype', '').strip() == 'all traffic': + continue + fb_tx, fb_rx = fallback_max_bw.get(row.get('ifname', '').strip(), (None, None)) + if not _nonzero(row.get('max_bw_tx')) and _nonzero(fb_tx): + row['max_bw_tx'] = fb_tx + if not _nonzero(row.get('max_bw_rx')) and _nonzero(fb_rx): + row['max_bw_rx'] = fb_rx + yield cls(row, logger) + + +@dataclass(init=False, slots=True) +class InterfacePeakStats: + ifname: str + peak_bytes_tx: float | None + peak_bytes_rx: float | None + peak_fwdrops_pkts_tx: float | None + peak_fwdrops_pkts_rx: float | None + peak_fwdrops_bytes_tx: float | None + peak_fwdrops_bytes_rx: float | None + peak_max_bw_tx: float | None + peak_max_bw_rx: float | None + traftype: str + _log: CheckLoggingAdapter + + def __init__(self, row: dict[str, str], logger: CheckLoggingAdapter) -> None: + self.ifname = v.strip() if (v := row.get('ifname')) is not None else "" + self.peak_bytes_tx = _get_float(row.get('bytes_tx')) + self.peak_bytes_rx = _get_float(row.get('bytes_rx')) + self.peak_fwdrops_pkts_tx = _get_float(row.get('fwdrops_pkts_tx')) + self.peak_fwdrops_pkts_rx = _get_float(row.get('fwdrops_pkts_rx')) + self.peak_fwdrops_bytes_tx = _get_float(row.get('fwdrops_bytes_tx')) + self.peak_fwdrops_bytes_rx = _get_float(row.get('fwdrops_bytes_rx')) + self.peak_max_bw_tx = _get_float(row.get('max_bw_tx')) + self.peak_max_bw_rx = _get_float(row.get('max_bw_rx')) + self.traftype = v.strip() if (v := row.get('traftype')) is not None else "" + self._log = logger + + def record( + self, + store: MetricsStore, + base_tags: list[str], + device_id: str, + max_bw: tuple[float | None, float | None], + ) -> None: + tags = base_tags + [ + f'interface_name:{self.ifname}', + f'traffic_type:{self.traftype}', + f'{NDM_INTERFACE_RESOURCE_TAG}:{device_id}', + ] + store.record('interface.bandwidth.tx.max', self.peak_bytes_tx, tags, AggType.MAX) + store.record('interface.bandwidth.rx.max', self.peak_bytes_rx, tags, AggType.MAX) + store.record('interface.drops.packets.tx.max', self.peak_fwdrops_pkts_tx, tags, AggType.MAX) + store.record('interface.drops.packets.rx.max', self.peak_fwdrops_pkts_rx, tags, AggType.MAX) + store.record('interface.drops.bytes.tx.max', self.peak_fwdrops_bytes_tx, tags, AggType.MAX) + store.record('interface.drops.bytes.rx.max', self.peak_fwdrops_bytes_rx, tags, AggType.MAX) + max_bw_tx, max_bw_rx = max_bw + if not max_bw_tx or not max_bw_rx: + self._log.warning("Max bandwidth is not available for %s, skipping peak utilization metrics", self.ifname) + return + if self.peak_bytes_tx is not None: + peak_bw_tx = self.peak_bytes_tx / MINUTE_STATS_INTERVAL + store.record('interface.utilization.tx.max', peak_bw_tx / max_bw_tx * 100, tags, AggType.MAX) + if self.peak_bytes_rx is not None: + peak_bw_rx = self.peak_bytes_rx / MINUTE_STATS_INTERVAL + store.record('interface.utilization.rx.max', peak_bw_rx / max_bw_rx * 100, tags, AggType.MAX) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter) -> Iterator[InterfacePeakStats]: + reader = csv.DictReader(StringIO(content)) + for row in reader: + if row.get('traftype', '').strip() == 'all traffic': + continue + yield cls(row, logger) + + +@dataclass(init=False, slots=True) +class InterfaceOverlayStats: + ifname: str + bytes_tx: float | None + bytes_rx: float | None + max_bw_tx: float | None + max_bw_rx: float | None + + def __init__(self, row: dict[str, str]) -> None: + self.ifname = v.strip() if (v := row.get('ifname')) is not None else "" + self.bytes_tx = _get_float(row.get('bytes_tx')) + self.bytes_rx = _get_float(row.get('bytes_rx')) + self.max_bw_tx = _get_float(row.get('max_bw_tx')) + self.max_bw_rx = _get_float(row.get('max_bw_rx')) + + def record(self, store: MetricsStore, base_tags: list[str], device_id: str) -> None: + tags = base_tags + [ + f'interface_name:{self.ifname}', + f'{NDM_INTERFACE_RESOURCE_TAG}:{device_id}', + ] + store.record('tunnel.internet_breakout.bandwidth.tx.count', self.bytes_tx, tags, AggType.SUM) + store.record('tunnel.internet_breakout.bandwidth.rx.count', self.bytes_rx, tags, AggType.SUM) + if self.bytes_tx is not None: + store.record( + 'tunnel.internet_breakout.bandwidth.tx.rate', self.bytes_tx / MINUTE_STATS_INTERVAL, tags, AggType.AVG + ) + if self.bytes_rx is not None: + store.record( + 'tunnel.internet_breakout.bandwidth.rx.rate', self.bytes_rx / MINUTE_STATS_INTERVAL, tags, AggType.AVG + ) + store.record('tunnel.internet_breakout.bandwidth.tx.max', self.max_bw_tx, tags, AggType.MAX) + store.record('tunnel.internet_breakout.bandwidth.rx.max', self.max_bw_rx, tags, AggType.MAX) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[InterfaceOverlayStats]: + reader = csv.DictReader(StringIO(content)) + for row in reader: + if int(row.get('tuntype') or '0') != TUNNEL_TYPE_INTERNET_BREAKOUT: + continue + yield cls(row) + + +@dataclass(init=False, slots=True) +class DscpStats: + dscp: str + traftype: str + bytes_wan_tx: float | None + bytes_wan_rx: float | None + bytes_lan_tx: float | None + bytes_lan_rx: float | None + + def __init__(self, row: dict[str, str]) -> None: + self.dscp = v.strip() if (v := row.get('dscp')) is not None else "" + self.traftype = v.strip() if (v := row.get('traftype')) is not None else "" + self.bytes_wan_tx = _get_float(row.get('bytes_wtx')) + self.bytes_wan_rx = _get_float(row.get('bytes_wrx')) + self.bytes_lan_tx = _get_float(row.get('bytes_ltx')) + self.bytes_lan_rx = _get_float(row.get('bytes_lrx')) + + def record(self, store: MetricsStore, base_tags: list[str]) -> None: + dscp_tags = base_tags + [f'dscp:{self.dscp}', f'traffic_type:{self.traftype}'] + store.record('qos.class.bandwidth.tx.count', self.bytes_wan_tx, dscp_tags + ['side:wan'], AggType.SUM) + store.record('qos.class.bandwidth.rx.count', self.bytes_wan_rx, dscp_tags + ['side:wan'], AggType.SUM) + store.record('qos.class.bandwidth.tx.count', self.bytes_lan_tx, dscp_tags + ['side:lan'], AggType.SUM) + store.record('qos.class.bandwidth.rx.count', self.bytes_lan_rx, dscp_tags + ['side:lan'], AggType.SUM) + if self.bytes_wan_tx is not None: + store.record( + 'qos.class.bandwidth.tx.rate', + self.bytes_wan_tx / MINUTE_STATS_INTERVAL, + dscp_tags + ['side:wan'], + AggType.AVG, + ) + if self.bytes_wan_rx is not None: + store.record( + 'qos.class.bandwidth.rx.rate', + self.bytes_wan_rx / MINUTE_STATS_INTERVAL, + dscp_tags + ['side:wan'], + AggType.AVG, + ) + if self.bytes_lan_tx is not None: + store.record( + 'qos.class.bandwidth.tx.rate', + self.bytes_lan_tx / MINUTE_STATS_INTERVAL, + dscp_tags + ['side:lan'], + AggType.AVG, + ) + if self.bytes_lan_rx is not None: + store.record( + 'qos.class.bandwidth.rx.rate', + self.bytes_lan_rx / MINUTE_STATS_INTERVAL, + dscp_tags + ['side:lan'], + AggType.AVG, + ) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[DscpStats]: + reader = csv.DictReader(StringIO(content)) + for row in reader: + if row.get('traftype', '').strip() == 'all traffic': + continue + yield cls(row) + + +@dataclass(init=False, slots=True) +class DscpPeakStats: + dscp: str + traftype: str + peak_bytes_wan_tx: float | None + peak_bytes_wan_rx: float | None + peak_bytes_lan_tx: float | None + peak_bytes_lan_rx: float | None + + def __init__(self, row: dict[str, str]) -> None: + self.dscp = v.strip() if (v := row.get('dscp')) is not None else "" + self.traftype = v.strip() if (v := row.get('traftype')) is not None else "" + self.peak_bytes_wan_tx = _get_float(row.get('bytes_wtx')) + self.peak_bytes_wan_rx = _get_float(row.get('bytes_wrx')) + self.peak_bytes_lan_tx = _get_float(row.get('bytes_ltx')) + self.peak_bytes_lan_rx = _get_float(row.get('bytes_lrx')) + + def record(self, store: MetricsStore, base_tags: list[str]) -> None: + dscp_tags = base_tags + [f'dscp:{self.dscp}', f'traffic_type:{self.traftype}'] + wan_tags = dscp_tags + ['side:wan'] + store.record('qos.class.bandwidth.tx.max', self.peak_bytes_wan_tx, wan_tags, AggType.MAX) + store.record('qos.class.bandwidth.rx.max', self.peak_bytes_wan_rx, wan_tags, AggType.MAX) + lan_tags = dscp_tags + ['side:lan'] + store.record('qos.class.bandwidth.tx.max', self.peak_bytes_lan_tx, lan_tags, AggType.MAX) + store.record('qos.class.bandwidth.rx.max', self.peak_bytes_lan_rx, lan_tags, AggType.MAX) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[DscpPeakStats]: + reader = csv.DictReader(StringIO(content)) + for row in reader: + if row.get('traftype', '').strip() == 'all traffic': + continue + yield cls(row) + + +@dataclass(init=False, slots=True) +class ProbeStats: + probe_name: str + avg_latency: float | None + avg_loss: float | None + avg_jitter: float | None + admin_up: float | None + oper_up: float | None + + def __init__(self, cols: list[str]) -> None: + n = len(cols) + self.probe_name = cols[PROBE_COL_PROBE_NAME] if n > PROBE_COL_PROBE_NAME else '' + self.avg_latency = _get_float(cols[PROBE_COL_AVG_LATENCY]) if n > PROBE_COL_AVG_LATENCY else None + self.avg_loss = _get_float(cols[PROBE_COL_AVG_LOSS]) if n > PROBE_COL_AVG_LOSS else None + self.avg_jitter = _get_float(cols[PROBE_COL_AVG_JITTER]) if n > PROBE_COL_AVG_JITTER else None + self.admin_up = _get_float(cols[PROBE_COL_ADMIN_UP]) if n > PROBE_COL_ADMIN_UP else None + self.oper_up = _get_float(cols[PROBE_COL_OPER_UP]) if n > PROBE_COL_OPER_UP else None + + def record(self, store: MetricsStore, base_tags: list[str]) -> None: + tags = base_tags + [f'probe_name:{self.probe_name}'] + store.record('circuit.sla.latency', self.avg_latency, tags, AggType.AVG) + store.record('circuit.sla.loss', self.avg_loss, tags, AggType.AVG) + store.record('circuit.sla.jitter', self.avg_jitter, tags, AggType.AVG) + store.record('nexthop.status', self.admin_up, tags + ['status_type:admin'], AggType.LAST) + store.record('nexthop.status', self.oper_up, tags + ['status_type:oper'], AggType.LAST) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[ProbeStats]: + for line in content.splitlines(): + line = line.strip() + if not line: + continue + yield cls(line.split(',')) + + +SHAPER_DIRECTION_LABELS = {'0': 'outbound', '1': 'inbound'} + + +@dataclass(init=False, slots=True) +class ShaperStats: + traffic_class: str + direction: str + qos_drops: float | None + other_drops: float | None + total_shaped_packets: float | None + + def __init__(self, row: dict[str, str]) -> None: + self.traffic_class = v.strip() if (v := row.get('traffic_class')) is not None else "" + raw_direction = v.strip() if (v := row.get('direction')) is not None else "" + self.direction = SHAPER_DIRECTION_LABELS.get(raw_direction, raw_direction) + self.qos_drops = _get_float(row.get('qos_drops')) + self.other_drops = _get_float(row.get('other_drops')) + self.total_shaped_packets = _get_float(row.get('shaped_packets')) + + def record( + self, + store: MetricsStore, + base_tags: list[str], + traffic_class_map: dict[str, str] | None = None, + logger: CheckLoggingAdapter | None = None, + ) -> None: + tags = base_tags + [f'direction:{self.direction}'] + if traffic_class_map: + overlay_name = traffic_class_map.get(self.traffic_class) + if overlay_name: + tags.append(f'overlay_name:{overlay_name}') + elif logger is not None: + logger.debug( + "No overlay name mapping found for traffic class %s; emitting metric without overlay_name tag", + self.traffic_class, + ) + store.record('qos.class.drops', self.qos_drops, tags + ['drop_type:qos'], AggType.SUM) + store.record('qos.class.drops', self.other_drops, tags + ['drop_type:other'], AggType.SUM) + if self.qos_drops is not None and self.total_shaped_packets is not None and self.total_shaped_packets > 0: + store.record( + 'qos.class.drop.percentage', self.qos_drops / self.total_shaped_packets * 100, tags, AggType.AVG + ) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[ShaperStats]: + reader = csv.DictReader(StringIO(content), skipinitialspace=True) + for row in reader: + yield cls(row) + + +@dataclass(init=False, slots=True) +class AppperfStats: + app_name: str + tunnel_name: str + transport_type: str + cnd_delay: float | None + snd_delay: float | None + app_delay: float | None + + def __init__(self, cols: list[str]) -> None: + n = len(cols) + self.app_name = cols[APPPERF_COL_APP_NAME] if n > APPPERF_COL_APP_NAME else "" + self.tunnel_name = cols[APPPERF_COL_TUNNEL_NAME] if n > APPPERF_COL_TUNNEL_NAME else "" + self.transport_type = cols[APPPERF_COL_TRANSPORT_TYPE] if n > APPPERF_COL_TRANSPORT_TYPE else "" + self.cnd_delay = _get_float(cols[APPPERF_COL_CND_DELAY]) if n > APPPERF_COL_CND_DELAY else None + self.snd_delay = _get_float(cols[APPPERF_COL_SND_DELAY]) if n > APPPERF_COL_SND_DELAY else None + self.app_delay = _get_float(cols[APPPERF_COL_APP_DELAY]) if n > APPPERF_COL_APP_DELAY else None + + def record(self, store: MetricsStore, base_tags: list[str]) -> None: + app_tags = base_tags + [f'application:{self.app_name}'] + if self.tunnel_name: + app_tags = app_tags + [f'tunnel_name:{self.tunnel_name}'] + if self.transport_type: + app_tags = app_tags + [f'transport_type:{self.transport_type}'] + for latency_type, value in [ + ('cnd', self.cnd_delay), + ('snd', self.snd_delay), + ('app', self.app_delay), + ]: + if value is not None: + store.record('application.latency', value, app_tags + [f'latency_type:{latency_type}'], AggType.AVG) + + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter | None = None) -> Iterator[AppperfStats]: + for line in content.splitlines(): + line = line.strip() + if not line: + continue + yield cls(line.split(',')) + + +class _Parseable(Protocol): + @classmethod + def parse(cls, content: str, logger: CheckLoggingAdapter) -> Iterator: ... + + +@dataclass(init=False, slots=True) +class MinuteStats: + """Represents the contents of a per-appliance minute-stats .tgz archive.""" + + PARSERS: ClassVar[list[tuple[str, str, type[_Parseable]]]] = [ + ('interface.csv', 'interfaces', InterfaceStats), + ('interface_peak.csv', 'interface_peaks', InterfacePeakStats), + ('tunnel_v2.txt', 'tunnels', TunnelV2Stats), + ('tunnel_peak.csv', 'tunnel_peaks', TunnelPeakStats), + ('jitter.csv', 'jitter', JitterStats), + ('mos.csv', 'mos', MosStats), + ('dscp.csv', 'dscp', DscpStats), + ('dscp_peak.csv', 'dscp_peaks', DscpPeakStats), + ('tunnel_availability_v2.txt', 'tunnel_availability', TunnelAvailability), + ('interface_overlay.csv', 'interface_overlays', InterfaceOverlayStats), + ('probe_v2.txt', 'probes', ProbeStats), + ('shaper.csv', 'shaper', ShaperStats), + ('appperf_v2.txt', 'appperf', AppperfStats), + ] + FILES_NEEDED: ClassVar[frozenset[str]] = frozenset(filename for filename, _, _ in PARSERS) + + appliance_ip: str + timestamp: int + interfaces: list[InterfaceStats] + interface_peaks: list[InterfacePeakStats] + tunnels: list[TunnelV2Stats] + tunnel_peaks: list[TunnelPeakStats] + jitter: list[JitterStats] + mos: list[MosStats] + dscp: list[DscpStats] + dscp_peaks: list[DscpPeakStats] + tunnel_availability: list[TunnelAvailability] + interface_overlays: list[InterfaceOverlayStats] + probes: list[ProbeStats] + shaper: list[ShaperStats] + appperf: list[AppperfStats] + files: dict[str, str] + _log: CheckLoggingAdapter + + def __init__(self, data: bytes, appliance_ip: str, timestamp: int, logger: CheckLoggingAdapter) -> None: + self.appliance_ip = appliance_ip + self.timestamp = timestamp + self._log = logger + + self.files = {} + with tarfile.open(fileobj=BytesIO(data), mode='r:gz') as tf: + for member in tf.getmembers(): + basename = os.path.basename(member.name) + if basename not in self.FILES_NEEDED: + continue + f = tf.extractfile(member) + if f is None: + continue + self.files[basename] = f.read().decode('utf-8') + + self._log.debug( + "Parsing minute-stats archive for %s at timestamp %d (%d files found)", + appliance_ip, + timestamp, + len(self.files), + ) + for required_file in self.FILES_NEEDED: + if required_file not in self.files: + self._log.warning( + "File %s not found in archive for appliance %s at timestamp %d", + required_file, + appliance_ip, + timestamp, + ) + + for filename, field_name, parser_cls in self.PARSERS: + setattr(self, field_name, self._safe_parse(filename, parser_cls)) + + def _safe_parse(self, filename: str, parser_cls: type[_Parseable]) -> list: + try: + return list(parser_cls.parse(self.get(filename), self._log)) + except Exception: + self._log.exception( + "Failed to parse %s for appliance %s at timestamp %d; skipping this file", + filename, + self.appliance_ip, + self.timestamp, + ) + return [] + + def get(self, filename: str) -> str: + return self.files.get(filename, '') + + def record( + self, + store: MetricsStore, + base_tags: list[str], + device_id: str, + traffic_class_map: dict[str, str] | None = None, + overlay_map: dict[str, str] | None = None, + ) -> None: + self._record_interface_stats(store, base_tags, device_id) + self._record_tunnel_stats(store, base_tags, overlay_map or {}) + self._record_tunnel_availability_stats(store, base_tags) + self._record_internet_breakout_stats(store, base_tags, device_id) + self._record_probe_stats(store, base_tags) + self._record_shaper_stats(store, base_tags, traffic_class_map or {}) + self._record_appperf_stats(store, base_tags) + self._record_dscp_stats(store, base_tags) + + def _record_interface_stats(self, store: MetricsStore, base_tags: list[str], device_id: str) -> None: + iface_max_bw: dict[str, tuple[float, float]] = {} + for iface in self.interfaces: + iface.record(store, base_tags, device_id) + last_max_bw = iface_max_bw.get(iface.ifname, (0.0, 0.0)) + iface_max_bw[iface.ifname] = ( + max(iface.max_bw_tx or 0.0, last_max_bw[0]), + max(iface.max_bw_rx or 0.0, last_max_bw[1]), + ) + for peak in self.interface_peaks: + peak.record(store, base_tags, device_id, iface_max_bw.get(peak.ifname, (None, None))) + + def _record_tunnel_stats(self, store: MetricsStore, base_tags: list[str], overlay_map: dict[str, str]) -> None: + tunnel_tags_by_id: dict[str, list[str]] = {} + for tun in self.tunnels: + tunnel_tags_by_id[tun.tunnel_id] = tun.record(store, base_tags, overlay_map) + for peak in self.tunnel_peaks: + peak.record(store, base_tags, tunnel_tags_by_id.get(peak.tunname, [])) + for jitter in self.jitter: + tags = base_tags + [f'tunnel_name:{jitter.tunnel}'] + tunnel_tags_by_id.get(jitter.tunnel, []) + jitter.record(store, tags) + for mos in self.mos: + tags = base_tags + [f'tunnel_name:{mos.tunnel}'] + tunnel_tags_by_id.get(mos.tunnel, []) + mos.record(store, tags) + + def _record_tunnel_availability_stats(self, store: MetricsStore, base_tags: list[str]) -> None: + for row in self.tunnel_availability: + row.record(store, base_tags) + + def _record_internet_breakout_stats(self, store: MetricsStore, base_tags: list[str], device_id: str) -> None: + for row in self.interface_overlays: + row.record(store, base_tags, device_id) + + def _record_probe_stats(self, store: MetricsStore, base_tags: list[str]) -> None: + for row in self.probes: + row.record(store, base_tags) + + def _record_shaper_stats( + self, store: MetricsStore, base_tags: list[str], traffic_class_map: dict[str, str] + ) -> None: + if self.shaper and not traffic_class_map: + self._log.warning( + "No traffic class to overlay name mapping available; shaper metrics will be emitted without " + "the overlay_name tag" + ) + for row in self.shaper: + row.record(store, base_tags, traffic_class_map, self._log) + + def _record_appperf_stats(self, store: MetricsStore, base_tags: list[str]) -> None: + for row in self.appperf: + row.record(store, base_tags) + + def _record_dscp_stats(self, store: MetricsStore, base_tags: list[str]) -> None: + for dscp in self.dscp: + dscp.record(store, base_tags) + for peak in self.dscp_peaks: + peak.record(store, base_tags) diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/ndm_models.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/ndm_models.py new file mode 100644 index 0000000000000..1bf8f6bcbb9d5 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/ndm_models.py @@ -0,0 +1,227 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import re +import time +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, Field + +from .minute_stats import parse_tunnel_alias + +if TYPE_CHECKING: + from datadog_checks.base.log import CheckLoggingAdapter + + from .appliance_models import Appliance + from .minute_stats import TunnelV2Stats + +INTEGRATION = 'hpe_aruba_edgeconnect' +VENDOR = 'aruba' +OS_NAME = 'ECOS' +PAYLOAD_METADATA_BATCH_SIZE = 100 + +STATUS_REACHABLE = 1 +STATUS_UNREACHABLE = 2 + +STATUS_UP = 1 +STATUS_DOWN = 2 +OPER_STATUS_UNKNOWN = 4 + +VLAN_RE = re.compile(r':v(\d+)$') + +# https://github.com/DataDog/datadog-agent/blob/main/pkg/collector/corechecks/snmp/internal/report/report_device_metadata.go#L46C1-L61C2 +SUPPORTED_DEVICE_TYPES = frozenset( + { + 'access_point', + 'firewall', + 'load_balancer', + 'pdu', + 'printer', + 'router', + 'sd-wan', + 'sensor', + 'server', + 'storage', + 'switch', + 'ups', + 'wlc', + } +) + + +class DeviceMetadata(BaseModel): + integration: str = INTEGRATION + id: str + id_tags: list[str] + tags: list[str] + ip_address: str + status: int + name: str + vendor: str + serial_number: str + location: str + version: str + product_name: str + os_name: str + device_type: str + site_id: str + site_name: str + namespace: str + + +class InterfaceMetadata(BaseModel): + integration: str = INTEGRATION + device_id: str + raw_id: str + raw_id_type: str = 'name' + id_tags: list[str] + name: str + mac_address: str + admin_status: int | None = None + oper_status: int + vlan: int | None = None + + +class TunnelMetadata(BaseModel): + integration: str = INTEGRATION + tunnel_id: str + src_device_id: str + dst_device_id: str + src_site_id: str | None = None + dst_site_id: str | None = None + overlay_name: str | None = None + path_name: str + tunnel_color: str + + +class NetworkDevicesMetadata(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + integration: str = INTEGRATION + namespace: str + devices: list[DeviceMetadata] = Field(default_factory=list) + interfaces: list[InterfaceMetadata] = Field(default_factory=list) + tunnels: list[TunnelMetadata] = Field(default_factory=list) + collect_timestamp: int | None = None + size: int = Field(default=0, exclude=True) + + def append(self, item: DeviceMetadata | InterfaceMetadata | TunnelMetadata) -> None: + if isinstance(item, DeviceMetadata): + self.devices.append(item) + elif isinstance(item, InterfaceMetadata): + self.interfaces.append(item) + elif isinstance(item, TunnelMetadata): + self.tunnels.append(item) + self.size += 1 + + +def create_device_metadata(appliance: Appliance, namespace: str) -> DeviceMetadata: + device_id = f'{namespace}:{appliance.ip}' + site = appliance.site or 'unknown' + return DeviceMetadata( + id=device_id, + id_tags=[ + f'device_namespace:{namespace}', + f'device_ip:{appliance.ip}', + ], + tags=[ + f'device_namespace:{namespace}', + f'device_ip:{appliance.ip}', + f'device_hostname:{appliance.host_name}', + f'device_id:{device_id}', + ], + ip_address=appliance.ip, + status=STATUS_REACHABLE if appliance.is_reachable else STATUS_UNREACHABLE, + name=appliance.host_name, + vendor=VENDOR, + serial_number=appliance.serial, + location=site, + version=appliance.software_version, + product_name=appliance.model, + os_name=OS_NAME, + device_type=appliance.mode if appliance.mode in SUPPORTED_DEVICE_TYPES else 'other', + site_id=site, + site_name=site, + namespace=namespace, + ) + + +def create_interface_metadata(appliance_ip: str, iface: dict[str, Any], namespace: str) -> InterfaceMetadata: + ifname = iface.get('ifname', '') + return InterfaceMetadata( + device_id=f'{namespace}:{appliance_ip}', + raw_id=ifname, + id_tags=[f'interface:{ifname}'], + name=ifname, + mac_address=iface.get('mac', ''), + admin_status=_bool_to_status(iface.get('admin'), STATUS_UP, STATUS_DOWN), + oper_status=_bool_to_status(iface.get('oper'), STATUS_UP, STATUS_DOWN, unknown=OPER_STATUS_UNKNOWN), + vlan=_parse_vlan(ifname), + ) + + +def create_tunnel_metadata( + tunnel: TunnelV2Stats, + appliance_ip: str, + src_site: str | None, + namespace: str, + peer_lookup: dict[str, tuple[str, str]], + overlay_map: dict[str, str], + wan_labels: set[str], + log: CheckLoggingAdapter, +) -> TunnelMetadata: + peer_hostname, tunnel_color = parse_tunnel_alias(tunnel.tunnel_alias, wan_labels) + if not peer_hostname: + log.debug("Peer hostname is not present on the tunnel alias %r", tunnel.tunnel_alias) + peer_ip, peer_site = peer_lookup.get(peer_hostname, ('', '')) + if peer_hostname and not peer_ip: + log.warning( + "Could not find peer %r for tunnel %r in peer lookup, dst_device_id will be empty", + peer_hostname, + tunnel.tunnel_alias, + ) + return TunnelMetadata( + tunnel_id=tunnel.tunnel_id, + src_device_id=f'{namespace}:{appliance_ip}', + dst_device_id=f'{namespace}:{peer_ip}' if peer_ip else '', + src_site_id=src_site, + dst_site_id=peer_site, + overlay_name=overlay_map.get(tunnel.overlay_id) if overlay_map else None, + path_name=tunnel.tunnel_alias, + tunnel_color=tunnel_color, + ) + + +def batch_payloads( + namespace: str, + items: list[DeviceMetadata] | list[InterfaceMetadata] | list[TunnelMetadata], + collect_timestamp: int | None = None, +) -> Iterator[NetworkDevicesMetadata]: + if not items: + return + if collect_timestamp is None: + collect_timestamp = int(time.time()) + + payload = NetworkDevicesMetadata(namespace=namespace, collect_timestamp=collect_timestamp) + for item in items: + if payload.size >= PAYLOAD_METADATA_BATCH_SIZE: + yield payload + payload = NetworkDevicesMetadata(namespace=namespace, collect_timestamp=collect_timestamp) + payload.append(item) + + if payload.size > 0: + yield payload + + +def _bool_to_status(value: bool | None, up: int, down: int, unknown: int | None = None) -> int | None: + if value is None: + return unknown + return up if value else down + + +def _parse_vlan(ifname: str) -> int | None: + m = VLAN_RE.search(ifname) + return int(m.group(1)) if m else None diff --git a/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/utils.py b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/utils.py new file mode 100644 index 0000000000000..a41f794eb5449 --- /dev/null +++ b/hpe_aruba_edgeconnect/datadog_checks/hpe_aruba_edgeconnect/utils.py @@ -0,0 +1,27 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import re +from typing import Any + +SPEED_RE = re.compile(r'^(\d+)\s*(Mb/s|Gb/s|Kb/s)', re.IGNORECASE) + +SPEED_MULTIPLIERS = { + 'kb/s': 1_000, + 'mb/s': 1_000_000, + 'gb/s': 1_000_000_000, +} + + +def parse_speed(value: Any) -> float | None: + """Parse interface speed to bits per second.""" + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + m = SPEED_RE.match(str(value)) + if m: + return float(m.group(1)) * SPEED_MULTIPLIERS[m.group(2).lower()] + return None diff --git a/hpe_aruba_edgeconnect/hatch.toml b/hpe_aruba_edgeconnect/hatch.toml new file mode 100644 index 0000000000000..ddd2d9637625f --- /dev/null +++ b/hpe_aruba_edgeconnect/hatch.toml @@ -0,0 +1,14 @@ +[env.collectors.datadog-checks] +check-types = true +mypy-args = [ + "--explicit-package-bases", +] + +[[envs.default.matrix]] +python = ["3.13"] + +[envs.lab] +python = "3.13" + +[envs.lab.env-vars] +USE_EDGECONNECT_LAB = "True" diff --git a/hpe_aruba_edgeconnect/metadata.csv b/hpe_aruba_edgeconnect/metadata.csv new file mode 100644 index 0000000000000..e3b04df1e3db8 --- /dev/null +++ b/hpe_aruba_edgeconnect/metadata.csv @@ -0,0 +1,72 @@ +metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric,sample_tags +hpe_aruba_edgeconnect.application.latency,gauge,,millisecond,,Application latency.,0,hpe_aruba_edgeconnect,application latency,,application:myapp latency_type:snd +hpe_aruba_edgeconnect.circuit.sla.jitter,gauge,,millisecond,,Average SLA probe jitter.,0,hpe_aruba_edgeconnect,circuit sla jitter,,probe_name:om_passThrough_6 +hpe_aruba_edgeconnect.circuit.sla.latency,gauge,,millisecond,,Average SLA probe latency.,0,hpe_aruba_edgeconnect,circuit sla latency,,probe_name:om_passThrough_6 +hpe_aruba_edgeconnect.circuit.sla.loss,gauge,,percent,,Average SLA probe packet loss.,0,hpe_aruba_edgeconnect,circuit sla loss,,probe_name:om_passThrough_6 +hpe_aruba_edgeconnect.device.cpu.usage,gauge,,percent,,"CPU usage percentage on the appliance broken down by state (user, system, IRQ, and nice).",0,hpe_aruba_edgeconnect,device cpu usage,,cpu_state:user +hpe_aruba_edgeconnect.device.disk.usage,gauge,,percent,,Disk usage percentage on the appliance per mount.,0,hpe_aruba_edgeconnect,device disk usage,,mount:/var +hpe_aruba_edgeconnect.device.hardware.ok,gauge,,,,"Hardware alarm status of the appliance (1 = ok, 0 = alarm active).",0,hpe_aruba_edgeconnect,device hardware ok,,device_ip:10.0.0.1 +hpe_aruba_edgeconnect.device.memory.usage,gauge,,percent,,Memory usage percentage on the appliance.,0,hpe_aruba_edgeconnect,device memory usage,,device_ip:10.0.0.1 +hpe_aruba_edgeconnect.device.reachability,gauge,,,,"Whether the appliance is reachable from the Orchestrator (1 = reachable, 0 = not reachable).",0,hpe_aruba_edgeconnect,device reachability,,device_hostname:SydneySP01 +hpe_aruba_edgeconnect.device.uptime,gauge,,second,,Uptime of the appliance in seconds.,0,hpe_aruba_edgeconnect,device uptime,,device_hostname:SydneySP01 +hpe_aruba_edgeconnect.interface.bandwidth.rx.count,gauge,,byte,,Bytes received on the interface. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,interface bandwidth rx count,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.bandwidth.rx.max,gauge,,byte,,Peak bytes received on the interface.,0,hpe_aruba_edgeconnect,interface bandwidth rx max,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.bandwidth.rx.rate,gauge,,byte,second,Receive rate on the interface in bytes per second.,0,hpe_aruba_edgeconnect,interface bandwidth rx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.bandwidth.tx.count,gauge,,byte,,Bytes transmitted on the interface. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,interface bandwidth tx count,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.bandwidth.tx.max,gauge,,byte,,Peak bytes transmitted on the interface.,0,hpe_aruba_edgeconnect,interface bandwidth tx max,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.bandwidth.tx.rate,gauge,,byte,second,Transmit rate on the interface in bytes per second.,0,hpe_aruba_edgeconnect,interface bandwidth tx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.bytes.rx.count,gauge,,byte,,Forward drop bytes received on the interface. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,interface drops bytes rx count,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.bytes.rx.max,gauge,,byte,,Peak forward drop bytes received on the interface.,0,hpe_aruba_edgeconnect,interface drops bytes rx max,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.bytes.rx.rate,gauge,,byte,second,Forward drop bytes received per second on the interface.,0,hpe_aruba_edgeconnect,interface drops bytes rx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.bytes.tx.count,gauge,,byte,,Forward drop bytes transmitted on the interface. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,interface drops bytes tx count,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.bytes.tx.max,gauge,,byte,,Peak forward drop bytes transmitted on the interface.,0,hpe_aruba_edgeconnect,interface drops bytes tx max,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.bytes.tx.rate,gauge,,byte,second,Forward drop bytes transmitted per second on the interface.,0,hpe_aruba_edgeconnect,interface drops bytes tx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.packets.rx.count,gauge,,packet,,Forward drop packets received on the interface. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,interface drops packets rx count,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.packets.rx.max,gauge,,packet,,Peak forward drop packets received on the interface.,0,hpe_aruba_edgeconnect,interface drops packets rx max,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.packets.rx.rate,gauge,,packet,second,Forward drop packets received per second on the interface.,0,hpe_aruba_edgeconnect,interface drops packets rx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.packets.tx.count,gauge,,packet,,Forward drop packets transmitted on the interface. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,interface drops packets tx count,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.packets.tx.max,gauge,,packet,,Peak forward drop packets transmitted on the interface.,0,hpe_aruba_edgeconnect,interface drops packets tx max,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.drops.packets.tx.rate,gauge,,packet,second,Forward drop packets transmitted per second on the interface.,0,hpe_aruba_edgeconnect,interface drops packets tx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.speed,gauge,,bit,second,Configured speed of the interface in bits per second.,0,hpe_aruba_edgeconnect,interface speed,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.status,gauge,,,,"Administrative or operational status of the interface (1 = up, 0 = down).",0,hpe_aruba_edgeconnect,interface status,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.utilization.rx.avg,gauge,,percent,,Average receive utilization percentage of the interface.,0,hpe_aruba_edgeconnect,interface utilization rx avg,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.utilization.rx.max,gauge,,percent,,Peak receive utilization percentage of the interface.,0,hpe_aruba_edgeconnect,interface utilization rx max,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.utilization.tx.avg,gauge,,percent,,Average transmit utilization percentage of the interface.,0,hpe_aruba_edgeconnect,interface utilization tx avg,,interface_name:wan0 +hpe_aruba_edgeconnect.interface.utilization.tx.max,gauge,,percent,,Peak transmit utilization percentage of the interface.,0,hpe_aruba_edgeconnect,interface utilization tx max,,interface_name:wan0 +hpe_aruba_edgeconnect.nexthop.status,gauge,,,,Number of times per minute that the IP SLA next-hop's administrative or operational status is up.,0,hpe_aruba_edgeconnect,nexthop status,,probe_name:om_passThrough_6 status_type:admin +hpe_aruba_edgeconnect.orchestrator.reachability,gauge,,,,"Whether the Orchestrator is reachable and returning appliance data (1 = reachable, 0 = not reachable).",0,hpe_aruba_edgeconnect,orchestrator reachability,,orch_ip:10.0.0.1 +hpe_aruba_edgeconnect.qos.class.bandwidth.rx.count,gauge,,byte,,Bytes received for the DSCP class. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,qos class bandwidth rx count,,dscp:be side:wan +hpe_aruba_edgeconnect.qos.class.bandwidth.rx.max,gauge,,byte,,Peak receive bandwidth for the DSCP class in bytes.,0,hpe_aruba_edgeconnect,qos class bandwidth rx max,,dscp:be side:wan +hpe_aruba_edgeconnect.qos.class.bandwidth.rx.rate,gauge,,byte,second,Receive rate for the DSCP class in bytes per second.,0,hpe_aruba_edgeconnect,qos class bandwidth rx rate,,dscp:be side:wan +hpe_aruba_edgeconnect.qos.class.bandwidth.tx.count,gauge,,byte,,Bytes transmitted for the DSCP class. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,qos class bandwidth tx count,,dscp:be side:wan +hpe_aruba_edgeconnect.qos.class.bandwidth.tx.max,gauge,,byte,,Peak transmit bandwidth for the DSCP class in bytes.,0,hpe_aruba_edgeconnect,qos class bandwidth tx max,,dscp:be side:wan +hpe_aruba_edgeconnect.qos.class.bandwidth.tx.rate,gauge,,byte,second,Transmit rate for the DSCP class in bytes per second.,0,hpe_aruba_edgeconnect,qos class bandwidth tx rate,,dscp:be side:wan +hpe_aruba_edgeconnect.qos.class.drop.percentage,gauge,,percent,,Percentage of packets shaped that were dropped by QoS.,0,hpe_aruba_edgeconnect,qos class drop percentage,,overlay_name:RealTime +hpe_aruba_edgeconnect.qos.class.drops,gauge,,packet,,QoS or other dropped packets by the traffic shaper. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,qos class drops,,overlay_name:RealTime drop_type:qos +hpe_aruba_edgeconnect.tunnel.availability,gauge,,percent,,Percentage of time the tunnel was up during the interval.,0,hpe_aruba_edgeconnect,tunnel availability,,tunnel_name:pass-through +hpe_aruba_edgeconnect.tunnel.internet_breakout.bandwidth.rx.count,gauge,,byte,,Bytes received on the internet breakout tunnel. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,tunnel internet breakout bandwidth rx count,,interface_name:wan0 +hpe_aruba_edgeconnect.tunnel.internet_breakout.bandwidth.rx.max,gauge,,byte,,Maximum configured receive bandwidth on the internet breakout tunnel.,0,hpe_aruba_edgeconnect,tunnel internet breakout bandwidth rx max,,interface_name:wan0 +hpe_aruba_edgeconnect.tunnel.internet_breakout.bandwidth.rx.rate,gauge,,byte,second,Receive rate on the internet breakout tunnel in bytes per second.,0,hpe_aruba_edgeconnect,tunnel internet breakout bandwidth rx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.tunnel.internet_breakout.bandwidth.tx.count,gauge,,byte,,Bytes transmitted on the internet breakout tunnel. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,tunnel internet breakout bandwidth tx count,,interface_name:wan0 +hpe_aruba_edgeconnect.tunnel.internet_breakout.bandwidth.tx.max,gauge,,byte,,Maximum configured transmit bandwidth on the internet breakout tunnel.,0,hpe_aruba_edgeconnect,tunnel internet breakout bandwidth tx max,,interface_name:wan0 +hpe_aruba_edgeconnect.tunnel.internet_breakout.bandwidth.tx.rate,gauge,,byte,second,Transmit rate on the internet breakout tunnel in bytes per second.,0,hpe_aruba_edgeconnect,tunnel internet breakout bandwidth tx rate,,interface_name:wan0 +hpe_aruba_edgeconnect.tunnel.jitter,gauge,,millisecond,,Jitter on the tunnel.,0,hpe_aruba_edgeconnect,tunnel jitter,,tunnel_name:pass-through +hpe_aruba_edgeconnect.tunnel.jitter.max,gauge,,millisecond,,Peak jitter on the tunnel.,0,hpe_aruba_edgeconnect,tunnel jitter max,,tunnel_name:pass-through +hpe_aruba_edgeconnect.tunnel.latency,gauge,,millisecond,,Average latency on the tunnel.,0,hpe_aruba_edgeconnect,tunnel latency,,tunnel_name:pass-through +hpe_aruba_edgeconnect.tunnel.latency.max,gauge,,millisecond,,Peak latency on the tunnel.,0,hpe_aruba_edgeconnect,tunnel latency max,,tunnel_name:pass-through +hpe_aruba_edgeconnect.tunnel.latency.min,gauge,,millisecond,,Minimum latency on the tunnel.,0,hpe_aruba_edgeconnect,tunnel latency min,,tunnel_name:pass-through +hpe_aruba_edgeconnect.tunnel.loss,gauge,,percent,,Packet loss on the tunnel.,0,hpe_aruba_edgeconnect,tunnel loss,,tunnel_name:pass-through fec:post +hpe_aruba_edgeconnect.tunnel.qoe.mos,gauge,,,,Mean Opinion Score on the tunnel.,0,hpe_aruba_edgeconnect,tunnel qoe mos,,tunnel_name:pass-through fec:post +hpe_aruba_edgeconnect.tunnel.qoe.mos.min,gauge,,,,Minimum Mean Opinion Score on the tunnel.,0,hpe_aruba_edgeconnect,tunnel qoe mos min,,tunnel_name:pass-through fec:post +hpe_aruba_edgeconnect.tunnel.throughput.rx.bytes.count,gauge,,byte,,Bytes received on the tunnel. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,tunnel throughput rx bytes count,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.rx.bytes.max,gauge,,byte,,Peak bytes received on the tunnel.,0,hpe_aruba_edgeconnect,tunnel throughput rx bytes max,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.rx.bytes.rate,gauge,,byte,second,Receive throughput rate on the tunnel in bytes per second.,0,hpe_aruba_edgeconnect,tunnel throughput rx bytes rate,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.rx.packets.count,gauge,,packet,,Packets received on the tunnel. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,tunnel throughput rx packets count,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.rx.packets.max,gauge,,packet,,Peak packets received on the tunnel.,0,hpe_aruba_edgeconnect,tunnel throughput rx packets max,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.rx.packets.rate,gauge,,packet,second,Receive packet rate on the tunnel in packets per second.,0,hpe_aruba_edgeconnect,tunnel throughput rx packets rate,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.tx.bytes.count,gauge,,byte,,Bytes transmitted on the tunnel. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,tunnel throughput tx bytes count,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.tx.bytes.max,gauge,,byte,,Peak bytes transmitted on the tunnel.,0,hpe_aruba_edgeconnect,tunnel throughput tx bytes max,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.tx.bytes.rate,gauge,,byte,second,Transmit throughput rate on the tunnel in bytes per second.,0,hpe_aruba_edgeconnect,tunnel throughput tx bytes rate,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.tx.packets.count,gauge,,packet,,Packets transmitted on the tunnel. Cumulative across recovered minutes after Agent restart backfill.,0,hpe_aruba_edgeconnect,tunnel throughput tx packets count,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.tx.packets.max,gauge,,packet,,Peak packets transmitted on the tunnel.,0,hpe_aruba_edgeconnect,tunnel throughput tx packets max,,tunnel_name:pass-through side:wan +hpe_aruba_edgeconnect.tunnel.throughput.tx.packets.rate,gauge,,packet,second,Transmit packet rate on the tunnel in packets per second.,0,hpe_aruba_edgeconnect,tunnel throughput tx packets rate,,tunnel_name:pass-through side:wan diff --git a/hpe_aruba_edgeconnect/pyproject.toml b/hpe_aruba_edgeconnect/pyproject.toml new file mode 100644 index 0000000000000..a7765ccea8d4d --- /dev/null +++ b/hpe_aruba_edgeconnect/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = [ + "hatchling>=0.13.0", +] +build-backend = "hatchling.build" + +[project] +name = "datadog-hpe-aruba-edgeconnect" +description = "The HPE Aruba EdgeConnect check" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.13" +keywords = [ + "datadog", + "datadog agent", + "datadog check", + "hpe_aruba_edgeconnect", +] +authors = [ + { name = "Datadog", email = "packages@datadoghq.com" }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Private :: Do Not Upload", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Monitoring", +] +dependencies = [ + "datadog-checks-base>=37.27.0", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +deps = [] + +[project.urls] +Source = "https://github.com/DataDog/integrations-core" + +[tool.hatch.version] +path = "datadog_checks/hpe_aruba_edgeconnect/__about__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/datadog_checks", + "/tests", + "/manifest.json", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/datadog_checks/hpe_aruba_edgeconnect", +] +dev-mode-dirs = [ + ".", +] diff --git a/hpe_aruba_edgeconnect/tests/__init__.py b/hpe_aruba_edgeconnect/tests/__init__.py new file mode 100644 index 0000000000000..75c6647cb9233 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/hpe_aruba_edgeconnect/tests/common.py b/hpe_aruba_edgeconnect/tests/common.py new file mode 100644 index 0000000000000..cee9f51197cbe --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/common.py @@ -0,0 +1,635 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import io +import tarfile +from pathlib import Path +from unittest.mock import MagicMock + +from datadog_checks.hpe_aruba_edgeconnect.client import OrchestratorClient + +FIXTURE_DIR = Path(__file__).parent / 'fixtures' + + +def _pack_dir_to_tgz_bytes(directory: Path, file_overrides: dict[str, str] | None = None) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode='w:gz') as tf: + if not file_overrides: + tf.add(directory, arcname=directory.name) + else: + for path in sorted(directory.iterdir()): + arcname = f'{directory.name}/{path.name}' + data = file_overrides.get(path.name) + if data is None: + tf.add(path, arcname=arcname) + continue + encoded = data.encode('utf-8') + info = tarfile.TarInfo(arcname) + info.size = len(encoded) + tf.addfile(info, io.BytesIO(encoded)) + return buf.getvalue() + + +MINUTE_STATS_DIRS = sorted(p for p in FIXTURE_DIR.iterdir() if p.is_dir() and p.name.startswith('st2-')) +TGZ_BYTES = [_pack_dir_to_tgz_bytes(d) for d in MINUTE_STATS_DIRS] +NEWEST_TS = 100000060 +TGZ_DATA = {f'{d.name}.tgz': data for d, data in zip(MINUTE_STATS_DIRS, TGZ_BYTES)} +CHECK_MODULE = 'datadog_checks.hpe_aruba_edgeconnect.check' +NS = 'hpe_aruba_edgeconnect' +DEVICE_ID = 'default:10.0.0.1' +NDM_IFACE_RES = f'dd.internal.resource:ndm_interface:{DEVICE_ID}' +EXCLUDED_APPLIANCE_IP = '10.0.0.3' + +APPLIANCE_PAYLOAD = [ + { + 'hostName': 'SydneySP01', + 'model': 'EC-V', + 'state': 1, + 'systemBandwidth': 300000, + 'site': 'SYD', + 'ip': '10.0.0.1', + 'serial': 'SN001', + 'mode': 'router', + 'softwareVersion': '9.3.1', + }, + { + 'hostName': 'NewYorkSP01', + 'model': 'EC-V', + 'state': 1, + 'systemBandwidth': 300000, + 'site': 'NYC', + 'ip': '10.0.0.2', + 'serial': 'SN002', + 'mode': 'router', + 'softwareVersion': '9.3.1', + }, + { + 'hostName': 'SanFranSP02', + 'model': 'EC-V', + 'state': 1, + 'systemBandwidth': 300000, + 'site': 'SFO', + 'ip': '10.0.0.3', + 'serial': 'SN003', + 'mode': 'router', + 'softwareVersion': '9.3.1', + }, +] + +APPLIANCE_BY_IP = {a['ip']: a for a in APPLIANCE_PAYLOAD} + + +def _build_system_info(ip: str) -> dict: + """Build a system_info payload that matches the appliance in APPLIANCE_PAYLOAD for the given IP.""" + appliance = APPLIANCE_BY_IP.get(ip, APPLIANCE_PAYLOAD[0]) + version = appliance['softwareVersion'] + return { + 'hostName': appliance['hostName'], + 'applianceid': 193645, + 'model': f'{appliance["model"]} 209005002001 Rev 102786', + 'modelShort': appliance['model'], + 'platform': 'VMware', + 'status': 'Normal', + 'uptime': 816745152, + 'uptimeString': '9d 10h 52m 25s', + 'datetime': '2026/05/08 07:53:33 Etc/UTC', + 'timezone': 'Etc/UTC', + 'gmtOffset': 0, + 'release': f'ECOS {version}_102786', + 'releaseWithoutPrefix': f'{version}_102786', + 'serial': appliance['serial'], + 'uuid': '3dbcbf55-33e0-418f-b98c-3626f98cb0da', + 'nepk': '', + 'portalObjectId': '69f11f4fa66feceed31bde83', + 'deploymentMode': appliance['mode'], + 'inlineRouter': True, + 'licenseRequired': False, + 'isLicenseInstalled': False, + 'licenseExpiryDate': '', + 'licenseExpirationDaysLeft': 1.7976931348623157e308, + 'hasUnsavedChanges': False, + 'rebootRequired': False, + 'biosVersion': '6.00', + 'alarmSummary': { + 'num_cleared': 0, + 'num_critical': 0, + 'num_equipment_outstanding': 0, + 'num_major': 0, + 'num_minor': 0, + 'num_outstanding': 1, + 'num_raise_ignore': 0, + 'num_software_outstanding': 1, + 'num_tca_outstanding': 0, + 'num_traffic_class_outstanding': 0, + 'num_tunnel_outstanding': 0, + 'num_warning': 1, + }, + 'suricata': '6.0.10', + 'ccStatus': False, + 'sku': 'N/A', + } + + +SYSTEM_INFO_PAYLOAD = _build_system_info('10.0.0.1') + +EXPECTED_METRIC_COUNTS = { + # Device health (orchestrator + appliance client) + 'orchestrator.reachability': 1, + 'device.reachability': 1, + 'device.uptime': 1, + 'device.cpu.usage': 4, + 'device.memory.usage': 1, + 'device.disk.usage': 5, + 'device.hardware.ok': 1, + 'interface.status': 2, + 'interface.speed': 1, + 'interface.bandwidth.tx.count': 5, + 'interface.bandwidth.rx.count': 5, + 'interface.bandwidth.tx.rate': 5, + 'interface.bandwidth.rx.rate': 5, + 'interface.bandwidth.tx.max': 5, + 'interface.bandwidth.rx.max': 5, + 'interface.drops.bytes.tx.count': 5, + 'interface.drops.bytes.rx.count': 5, + 'interface.drops.bytes.tx.rate': 5, + 'interface.drops.bytes.rx.rate': 5, + 'interface.drops.bytes.tx.max': 5, + 'interface.drops.bytes.rx.max': 5, + 'interface.drops.packets.tx.count': 5, + 'interface.drops.packets.rx.count': 5, + 'interface.drops.packets.tx.rate': 5, + 'interface.drops.packets.rx.rate': 5, + 'interface.drops.packets.tx.max': 5, + 'interface.drops.packets.rx.max': 5, + 'interface.utilization.tx.avg': 5, + 'interface.utilization.rx.avg': 5, + 'interface.utilization.tx.max': 5, + 'interface.utilization.rx.max': 5, + 'tunnel.throughput.tx.bytes.count': 58, + 'tunnel.throughput.rx.bytes.count': 58, + 'tunnel.throughput.tx.bytes.rate': 58, + 'tunnel.throughput.rx.bytes.rate': 58, + 'tunnel.throughput.tx.packets.count': 58, + 'tunnel.throughput.rx.packets.count': 58, + 'tunnel.throughput.tx.packets.rate': 58, + 'tunnel.throughput.rx.packets.rate': 58, + 'tunnel.throughput.tx.bytes.max': 58, + 'tunnel.throughput.rx.bytes.max': 58, + 'tunnel.throughput.tx.packets.max': 58, + 'tunnel.throughput.rx.packets.max': 58, + 'tunnel.latency': 29, + 'tunnel.latency.min': 29, + 'tunnel.latency.max': 29, + 'tunnel.loss': 58, + 'tunnel.jitter': 31, + 'tunnel.jitter.max': 31, + 'tunnel.qoe.mos': 62, + 'tunnel.qoe.mos.min': 62, + 'tunnel.availability': 29, + 'tunnel.internet_breakout.bandwidth.tx.count': 2, + 'tunnel.internet_breakout.bandwidth.rx.count': 2, + 'tunnel.internet_breakout.bandwidth.tx.rate': 2, + 'tunnel.internet_breakout.bandwidth.rx.rate': 2, + 'tunnel.internet_breakout.bandwidth.tx.max': 2, + 'tunnel.internet_breakout.bandwidth.rx.max': 2, + 'circuit.sla.latency': 4, + 'circuit.sla.loss': 4, + 'circuit.sla.jitter': 4, + 'nexthop.status': 8, + 'qos.class.drops': 8, + 'qos.class.drop.percentage': 4, + 'qos.class.bandwidth.tx.count': 6, + 'qos.class.bandwidth.rx.count': 6, + 'qos.class.bandwidth.tx.rate': 6, + 'qos.class.bandwidth.rx.rate': 6, + 'qos.class.bandwidth.tx.max': 6, + 'qos.class.bandwidth.rx.max': 6, + 'application.latency': 6, +} + +BASE_DEVICE_TAGS = [ + 'orch_ip:localhost:8443', + 'device_namespace:default', + 'device_ip:10.0.0.1', + 'device_model:EC-V', + 'device_hostname:SydneySP01', + 'software_version:9.3.1', + 'device_vendor:aruba', + 'site_id:SYD', + 'site_name:SYD', + 'dd.internal.resource:ndm_device:default:10.0.0.1', + 'dd.internal.resource:ndm_device_user_tags:default:10.0.0.1', +] + +EXPECTED_VALUES = [ + ('device.reachability', 1, []), + ('device.uptime', 816745.152, []), + ('device.cpu.usage', 30.0, ['cpu_state:user']), + ('device.cpu.usage', 15.0, ['cpu_state:system']), + ('device.cpu.usage', 3.0, ['cpu_state:irq']), + ('device.cpu.usage', 2.0, ['cpu_state:nice']), + ('device.memory.usage', (3174232 / 3945080) * 100.0, []), + ('device.disk.usage', 21.0, ['mount:/']), + ('device.disk.usage', 11.0, ['mount:/var']), + ('device.hardware.ok', 0, []), + ('interface.status', 1, ['interface_name:wan0', 'status_type:admin', NDM_IFACE_RES]), + ('interface.status', 1, ['interface_name:wan0', 'status_type:oper', NDM_IFACE_RES]), + ('interface.speed', 1000000000, ['interface_name:wan0', NDM_IFACE_RES]), + ( + 'interface.bandwidth.tx.count', + 158508, + ['interface_name:wan0', 'traffic_type:pass-through-unshaped', NDM_IFACE_RES], + ), + ( + 'interface.bandwidth.tx.rate', + 1320.9, + ['interface_name:wan0', 'traffic_type:pass-through-unshaped', NDM_IFACE_RES], + ), + ( + 'interface.bandwidth.rx.count', + 82824, + ['interface_name:wan0', 'traffic_type:pass-through-unshaped', NDM_IFACE_RES], + ), + ( + 'interface.bandwidth.tx.max', + 1332, + ['interface_name:wan0', 'traffic_type:pass-through-unshaped', NDM_IFACE_RES], + ), + ( + 'interface.bandwidth.rx.max', + 696, + ['interface_name:wan0', 'traffic_type:pass-through-unshaped', NDM_IFACE_RES], + ), + ( + 'tunnel.latency', + 1.4, + [ + 'tunnel_name:tunnel_12', + 'tunnel_alias:to_NewYorkSP01_MPLS1-MPLS1', + 'overlay_name:business', + 'is_sdwan:false', + ], + ), + ( + 'tunnel.latency.min', + 1.38, + [ + 'tunnel_name:tunnel_12', + 'tunnel_alias:to_NewYorkSP01_MPLS1-MPLS1', + 'overlay_name:business', + 'is_sdwan:false', + ], + ), + ( + 'tunnel.jitter', + 350, + [ + 'tunnel_name:bondedTunnel_16', + 'tunnel_alias:to_NewYorkSP01_CriticalApps', + 'is_sdwan:false', + ], + ), + ( + 'tunnel.jitter.max', + 6, + [ + 'tunnel_name:bondedTunnel_16', + 'tunnel_alias:to_NewYorkSP01_CriticalApps', + 'is_sdwan:false', + ], + ), + ( + 'tunnel.qoe.mos', + 4.0, + [ + 'tunnel_name:tunnel_12', + 'tunnel_alias:to_NewYorkSP01_MPLS1-MPLS1', + 'overlay_name:business', + 'is_sdwan:false', + 'fec:post', + ], + ), + ( + 'tunnel.qoe.mos.min', + 4.0, + [ + 'tunnel_name:tunnel_12', + 'tunnel_alias:to_NewYorkSP01_MPLS1-MPLS1', + 'overlay_name:business', + 'is_sdwan:false', + 'fec:post', + ], + ), + ('tunnel.internet_breakout.bandwidth.tx.count', 76012, ['interface_name:wan0', NDM_IFACE_RES]), + ('tunnel.internet_breakout.bandwidth.tx.rate', 316.71666666666664, ['interface_name:wan0', NDM_IFACE_RES]), + ('tunnel.internet_breakout.bandwidth.rx.max', 100000, ['interface_name:wan0', NDM_IFACE_RES]), + ('qos.class.bandwidth.tx.count', 75684, ['dscp:be', 'traffic_type:pass-through-unshaped', 'side:wan']), + ('qos.class.bandwidth.tx.rate', 630.7, ['dscp:be', 'traffic_type:pass-through-unshaped', 'side:wan']), + ('qos.class.bandwidth.tx.max', 636, ['dscp:be', 'traffic_type:pass-through-unshaped', 'side:wan']), + ('qos.class.drops', 0, ['overlay_name:BulkData', 'drop_type:qos', 'direction:inbound']), + ('qos.class.drop.percentage', 0, ['overlay_name:BulkData', 'direction:inbound']), + ('circuit.sla.latency', 0, ['probe_name:om_passThrough_9']), + ('circuit.sla.loss', 0, ['probe_name:om_passThrough_9']), + ('circuit.sla.jitter', 59.5, ['probe_name:om_passThrough_9']), + ('nexthop.status', 60, ['probe_name:om_passThrough_6', 'status_type:admin']), + ('nexthop.status', 0, ['probe_name:om_passThrough_6', 'status_type:oper']), + ('application.latency', 5.1, ['application:microsoft', 'tunnel_name:bondedTunnel_16', 'latency_type:cnd']), +] + +ALARM_PAYLOAD = { + 'outstanding': [ + { + 'severity': 1, + 'sequenceId': 3777, + 'source': 'System', + 'acknowledged': False, + 'clearable': False, + 'time': 1779178081000, + 'description': 'All NTP servers are unreachable', + 'type': 'SW', + 'recommendation': ( + "Check appliance's NTP server IP and version config. Can appliance reach the NTP server? " + "Is UDP port 123 open between Appliance's mgmt0 IP and NTP server?" + ), + 'serviceAffect': True, + 'typeId': 262153, + 'name': 'ntpd_server_unreachable', + 'occurrenceCount': 1, + 'active': True, + 'ackedBy': '', + 'ackedTime': 0, + 'clearedBy': '', + 'clearedTime': 0, + 'note': '', + } + ], + 'summary': { + 'num_cleared': 0, + 'num_critical': 0, + 'num_equipment_outstanding': 0, + 'num_major': 0, + 'num_minor': 0, + 'num_outstanding': 1, + 'num_raise_ignore': 0, + 'num_software_outstanding': 1, + 'num_tca_outstanding': 0, + 'num_traffic_class_outstanding': 0, + 'num_tunnel_outstanding': 0, + 'num_warning': 1, + }, +} + +MEMORY_PAYLOAD = { + 'total': 3945080, + 'free': 770848, + 'buffers': 2516, + 'cached': 729568, + 'used': 3174232, +} + +DISK_PAYLOAD = { + '/dev': {'1k-blocks': 1965848, 'used': 0, 'available': 1965848, 'usedpercent': 0, 'filesystem': 'none'}, + '/': { + '1k-blocks': 6126976, + 'used': 1193348, + 'available': 4619060, + 'usedpercent': 21, + 'filesystem': '/dev/disk/by-label/ROOT_1', + }, + '/var': { + '1k-blocks': 42030588, + 'used': 4328968, + 'available': 35553256, + 'usedpercent': 11, + 'filesystem': '/root/dev/disk/by-label/VAR', + }, + '/boot': {'1k-blocks': 999288, 'used': 31676, 'available': 915188, 'usedpercent': 4, 'filesystem': '/dev/sda5'}, + '/bootmgr': {'1k-blocks': 999320, 'used': 3268, 'available': 943624, 'usedpercent': 1, 'filesystem': '/dev/sda1'}, + '/config': {'1k-blocks': 1015700, 'used': 1632, 'available': 961640, 'usedpercent': 1, 'filesystem': '/dev/sda3'}, + '/run': {'1k-blocks': 1972540, 'used': 4776, 'available': 1967764, 'usedpercent': 1, 'filesystem': 'tmpfs'}, + '/var/volatile': { + '1k-blocks': 1972540, + 'used': 2384, + 'available': 1970156, + 'usedpercent': 1, + 'filesystem': 'tmpfs', + }, +} + + +def _build_cpu_payload(usage): + return { + 'latestTimestamp': NEWEST_TS, + 'data': [ + { + str(NEWEST_TS): [ + { + 'cpu_number': 'ALL', + 'pUser': str(usage * 0.6), + 'pSys': str(usage * 0.3), + 'pIRQ': str(usage * 0.06), + 'pNice': str(usage * 0.04), + }, + ], + }, + ], + } + + +# --------------------------------------------------------------------------- +# Mock client builders +# --------------------------------------------------------------------------- + + +def _mock_orch_client(appliance_payload, overlay_config=None): + overlays_response = MagicMock( + raise_for_status=MagicMock(), + json=MagicMock(return_value=overlay_config if overlay_config is not None else []), + ) + + def http_get(url, **kwargs): + if url.endswith('/gms/rest/gms/overlays/config'): + return overlays_response + raise AssertionError(f'unexpected orchestrator GET request: {url}') + + http = MagicMock() + http.get.side_effect = http_get + client = OrchestratorClient(http, 'localhost:8443') + client.get_appliances = MagicMock(return_value=appliance_payload) + return client + + +def _mock_appliance_client( + tgz_data, + newest_timestamp=NEWEST_TS, + cpu=50, + mem=None, + disk=None, + alarms=None, + system_info=None, + app_ip='10.0.0.1', +): + client = MagicMock() + client.get_newest_timestamp.return_value = newest_timestamp + if isinstance(tgz_data, dict): + client.get_minute_stats.side_effect = lambda fname: tgz_data[fname] + else: + client.get_minute_stats.return_value = tgz_data + client.get_network_interfaces.return_value = { + 'ifInfo': [{'ifname': 'wan0', 'admin': 1, 'oper': 1, 'speed': '1000Mb/s (auto)'}] + } + client.get_cpu_stats.return_value = _build_cpu_payload(cpu) + client.get_memory_stats.return_value = mem if mem is not None else MEMORY_PAYLOAD + client.get_disk_usage.return_value = disk if disk is not None else DISK_PAYLOAD + client.get_alarms.return_value = ( + alarms if alarms is not None else {'outstanding': [{'type': 'HW'}, {'type': 'TUNNEL'}]} + ) + client.get_system_info.return_value = system_info if system_info is not None else _build_system_info(app_ip) + client.get_interface_labels.return_value = {'wan': {}, 'lan': {}} + client.app_ip = app_ip + return client + + +def _setup_mocks( + mocker, + check, + appliance_payload, + tgz_bytes=None, + appliance_client=None, + overlay_config=None, + cached_timestamp=None, +): + orch = _mock_orch_client(appliance_payload, overlay_config) + mocker.patch(f'{CHECK_MODULE}.OrchestratorClient', return_value=orch) + check._orch_client = None + + if appliance_client is not None: + mocker.patch.object(check, '_create_appliance_client', return_value=appliance_client) + elif tgz_bytes is not None: + mocker.patch.object(check, '_create_appliance_client', return_value=_mock_appliance_client(tgz_bytes)) + + mocker.patch.object(check, 'read_persistent_cache', return_value=cached_timestamp) + mocker.patch.object(check, 'write_persistent_cache') + return orch + + +# --------------------------------------------------------------------------- +# E2E expectations +# --------------------------------------------------------------------------- + +E2E_TUNNEL_AGGREGATE_ALIASES = ('all traffic', 'optimized traffic', 'pass-through', 'pass-through-unshaped') + +E2E_EXPECTED_METRIC_COUNTS = { + 'orchestrator.reachability': 1, + 'device.reachability': 2, + 'device.uptime': 1, + 'device.cpu.usage': 4, + 'device.memory.usage': 1, + 'device.disk.usage': 5, + 'device.hardware.ok': 1, + 'interface.status': 2, + 'interface.speed': 1, + 'interface.bandwidth.tx.count': 3, + 'interface.bandwidth.rx.count': 3, + 'interface.bandwidth.tx.rate': 3, + 'interface.bandwidth.rx.rate': 3, + 'interface.bandwidth.tx.max': 3, + 'interface.bandwidth.rx.max': 3, + 'interface.drops.bytes.tx.count': 3, + 'interface.drops.bytes.rx.count': 3, + 'interface.drops.bytes.tx.rate': 3, + 'interface.drops.bytes.rx.rate': 3, + 'interface.drops.bytes.tx.max': 3, + 'interface.drops.bytes.rx.max': 3, + 'interface.drops.packets.tx.count': 3, + 'interface.drops.packets.rx.count': 3, + 'interface.drops.packets.tx.rate': 3, + 'interface.drops.packets.rx.rate': 3, + 'interface.drops.packets.tx.max': 3, + 'interface.drops.packets.rx.max': 3, + 'interface.utilization.tx.avg': 3, + 'interface.utilization.rx.avg': 3, + 'interface.utilization.tx.max': 3, + 'interface.utilization.rx.max': 3, + 'tunnel.throughput.tx.bytes.count': 58, + 'tunnel.throughput.rx.bytes.count': 58, + 'tunnel.throughput.tx.bytes.rate': 58, + 'tunnel.throughput.rx.bytes.rate': 58, + 'tunnel.throughput.tx.packets.count': 58, + 'tunnel.throughput.rx.packets.count': 58, + 'tunnel.throughput.tx.packets.rate': 58, + 'tunnel.throughput.rx.packets.rate': 58, + 'tunnel.throughput.tx.bytes.max': 58, + 'tunnel.throughput.rx.bytes.max': 58, + 'tunnel.throughput.tx.packets.max': 58, + 'tunnel.throughput.rx.packets.max': 58, + 'tunnel.latency': 29, + 'tunnel.latency.min': 29, + 'tunnel.latency.max': 29, + 'tunnel.loss': 58, + 'tunnel.jitter': 31, + 'tunnel.jitter.max': 31, + 'tunnel.qoe.mos': 62, + 'tunnel.qoe.mos.min': 62, + 'tunnel.availability': 29, + 'tunnel.internet_breakout.bandwidth.tx.count': 1, + 'tunnel.internet_breakout.bandwidth.rx.count': 1, + 'tunnel.internet_breakout.bandwidth.tx.rate': 1, + 'tunnel.internet_breakout.bandwidth.rx.rate': 1, + 'tunnel.internet_breakout.bandwidth.tx.max': 1, + 'tunnel.internet_breakout.bandwidth.rx.max': 1, + 'circuit.sla.latency': 4, + 'circuit.sla.loss': 4, + 'circuit.sla.jitter': 4, + 'nexthop.status': 8, + 'qos.class.drops': 4, + 'qos.class.drop.percentage': 2, + 'qos.class.bandwidth.tx.count': 4, + 'qos.class.bandwidth.rx.count': 4, + 'qos.class.bandwidth.tx.rate': 4, + 'qos.class.bandwidth.rx.rate': 4, + 'qos.class.bandwidth.tx.max': 4, + 'qos.class.bandwidth.rx.max': 4, + 'application.latency': 6, +} + +E2E_EXPECTED_VALUES = [ + ('device.reachability', 1, []), + ('device.uptime', 86400, []), + ('device.cpu.usage', 30.0, ['cpu_state:user']), + ('device.cpu.usage', 15.0, ['cpu_state:system']), + ('device.cpu.usage', 3.0, ['cpu_state:irq']), + ('device.cpu.usage', 2.0, ['cpu_state:nice']), + ('device.memory.usage', (3174232 / 3945080) * 100.0, []), + ('device.disk.usage', 21.0, ['mount:/']), + ('device.disk.usage', 11.0, ['mount:/var']), + ('device.hardware.ok', 1, []), + ('interface.status', 1, ['interface_name:wan0', 'status_type:admin']), + ('interface.status', 1, ['interface_name:wan0', 'status_type:oper']), + ('interface.speed', 1000000000, ['interface_name:wan0']), + ('interface.bandwidth.tx.count', 79920, ['interface_name:wan0', 'traffic_type:pass-through-unshaped']), + ('interface.bandwidth.tx.rate', 1332.0, ['interface_name:wan0', 'traffic_type:pass-through-unshaped']), + ('interface.bandwidth.rx.count', 41760, ['interface_name:wan0', 'traffic_type:pass-through-unshaped']), + ('interface.bandwidth.tx.max', 1332, ['interface_name:wan0', 'traffic_type:pass-through-unshaped']), + ('interface.bandwidth.rx.max', 696, ['interface_name:wan0', 'traffic_type:pass-through-unshaped']), + ('tunnel.latency', 1.39, ['tunnel_alias:to_NewYorkSP01_MPLS1-MPLS1']), + ('tunnel.latency.min', 1.38, ['tunnel_alias:to_NewYorkSP01_MPLS1-MPLS1']), + ('tunnel.jitter', 600, ['tunnel_name:bondedTunnel_16']), + ('tunnel.jitter.max', 6, ['tunnel_name:bondedTunnel_16']), + ('tunnel.qoe.mos', 4.0, ['tunnel_name:tunnel_12', 'fec:post']), + ('tunnel.qoe.mos.min', 4.0, ['tunnel_name:tunnel_12', 'fec:post']), + ('tunnel.internet_breakout.bandwidth.tx.count', 38160, ['interface_name:wan0']), + ('tunnel.internet_breakout.bandwidth.tx.rate', 636.0, ['interface_name:wan0']), + ('tunnel.internet_breakout.bandwidth.rx.max', 100000, ['interface_name:wan0']), + ('qos.class.bandwidth.tx.count', 38160, ['dscp:be', 'side:wan']), + ('qos.class.bandwidth.tx.rate', 636.0, ['dscp:be', 'side:wan']), + ('qos.class.bandwidth.tx.max', 636, ['dscp:be', 'side:wan']), + ('qos.class.drops', 0, ['overlay_name:BulkData', 'drop_type:qos']), + ('qos.class.drop.percentage', 0, ['overlay_name:BulkData']), + ('circuit.sla.latency', 0, ['probe_name:om_passThrough_9']), + ('circuit.sla.loss', 0, ['probe_name:om_passThrough_9']), + ('circuit.sla.jitter', 60, ['probe_name:om_passThrough_9']), + ('nexthop.status', 60, ['probe_name:om_passThrough_6', 'status_type:admin']), + ('nexthop.status', 0, ['probe_name:om_passThrough_6', 'status_type:oper']), + ('application.latency', 5.0, ['application:microsoft', 'tunnel_name:bondedTunnel_16', 'latency_type:cnd']), +] diff --git a/hpe_aruba_edgeconnect/tests/conftest.py b/hpe_aruba_edgeconnect/tests/conftest.py new file mode 100644 index 0000000000000..4cc75a2556f65 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/conftest.py @@ -0,0 +1,147 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import os +import ssl +from urllib.request import urlopen + +import pytest + +from datadog_checks.dev import WaitFor, docker_run, get_docker_hostname, get_here +from datadog_checks.dev.utils import find_free_port +from datadog_checks.hpe_aruba_edgeconnect import HpeArubaEdgeconnectCheck + +from .common import ( + APPLIANCE_PAYLOAD, + EXCLUDED_APPLIANCE_IP, + TGZ_DATA, + _mock_appliance_client, + _setup_mocks, +) + +USE_EDGECONNECT_LAB = os.environ.get('USE_EDGECONNECT_LAB') + + +HERE = get_here() +HOST = get_docker_hostname() +COMPOSE_FILE = os.path.join(HERE, 'docker', 'docker-compose.yaml') + + +@pytest.fixture(scope='session') +def dd_environment(instance, dd_save_state): + if USE_EDGECONNECT_LAB: + orch_ip = os.environ['EDGECONNECT_ORCH_IP'] + orch_username = os.environ['EDGECONNECT_ORCH_USERNAME'] + orch_password = os.environ['EDGECONNECT_ORCH_PASSWORD'] + appliance_username = os.environ.get('EDGECONNECT_APPLIANCE_USERNAME', orch_username) + appliance_password = os.environ.get('EDGECONNECT_APPLIANCE_PASSWORD', orch_password) + appliance_credentials = [ + {'cidr': '0.0.0.0/0', 'username': appliance_username, 'password': appliance_password}, + ] + inst = instance( + orch_ip, + orchestrator_username=orch_username, + orchestrator_password=orch_password, + appliance_credentials_overrides=appliance_credentials, + send_ndm_metadata=True, + collect_events=True, + ) + dd_save_state('e2e_instance', inst) + yield { + 'instances': [inst], + 'logs': [ + { + 'type': 'tcp', + 'port': 10514, + 'service': 'edgeconnect', + 'source': 'aruba_edgeconnect', + } + ], + } + else: + orch_port = find_free_port(HOST) + appliance_port = find_free_port(HOST) + orch_ip = f'{HOST}:{orch_port}' + appliance_ip = f'{HOST}:{appliance_port}' + + def _ready(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + urlopen(f'https://{orch_ip}/health', timeout=2, context=ctx) + urlopen(f'https://{appliance_ip}/health', timeout=2, context=ctx) + + inst = instance( + orch_ip, + connect_timeout=2, + appliance_ips={'exclude': [f'{EXCLUDED_APPLIANCE_IP}/32']}, + ) + + with docker_run( + compose_file=COMPOSE_FILE, + build=True, + conditions=[WaitFor(_ready, attempts=60, wait=1)], + env_vars={ + 'HOST_PORT': str(orch_port), + 'APPLIANCE_PORT': str(appliance_port), + 'ORCH_USERNAME': 'admin', + 'ORCH_PASSWORD': '', + 'APPLIANCE_USERNAME': 'admin', + 'APPLIANCE_PASSWORD': '', + 'APPLIANCE_IP': appliance_ip, + }, + ): + yield {'instances': [inst]} + + +@pytest.fixture(scope='session') +def instance(): + def builder( + orchestrator_ip: str, + orchestrator_username: str = 'admin', + orchestrator_password: str = '', + appliance_ips=None, + appliance_credentials=None, + send_ndm_metadata: bool = False, + **kwargs, + ) -> dict: + inst = { + 'orchestrator_ip': orchestrator_ip, + 'orchestrator_username': orchestrator_username, + 'orchestrator_password': orchestrator_password, + 'tls_verify': False, + 'send_ndm_metadata': send_ndm_metadata, + **kwargs, + } + if appliance_ips is not None: + inst['appliance_ips'] = appliance_ips if isinstance(appliance_ips, dict) else {'include': appliance_ips} + if appliance_credentials is not None: + inst['appliance_credentials_overrides'] = appliance_credentials + return inst + + return builder + + +@pytest.fixture +def check(instance): + inst = instance('localhost:8443', appliance_ips=['10.0.0.1'], max_backfill_minutes=10) + return HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + +@pytest.fixture +def all_metrics_aggregator(dd_run_check, aggregator, mocker, check): + client = _mock_appliance_client(TGZ_DATA) + _setup_mocks( + mocker, + check, + APPLIANCE_PAYLOAD, + appliance_client=client, + cached_timestamp='99999940', + overlay_config=[ + {'id': 0, 'name': 'business'}, + {'name': 'BulkData', 'trafficClass': 2}, + {'name': 'RealTime', 'trafficClass': 4}, + ], + ) + dd_run_check(check) + return aggregator diff --git a/hpe_aruba_edgeconnect/tests/docker/docker-compose.yaml b/hpe_aruba_edgeconnect/tests/docker/docker-compose.yaml new file mode 100644 index 0000000000000..9480d0d044bf8 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/docker/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + orch: + build: + context: ../.. + dockerfile: tests/docker/fake_orch/Dockerfile + container_name: dd-orch + ports: + - "${HOST_PORT}:8443" + environment: + ORCH_USERNAME: "${ORCH_USERNAME}" + ORCH_PASSWORD: "${ORCH_PASSWORD}" + APPLIANCE_IP: "${APPLIANCE_IP}" + restart: "no" + + appliance: + build: + context: ../.. + dockerfile: tests/docker/fake_appliance/Dockerfile + container_name: dd-appliance + ports: + - "${APPLIANCE_PORT}:8444" + environment: + APPLIANCE_USERNAME: "${APPLIANCE_USERNAME}" + APPLIANCE_PASSWORD: "${APPLIANCE_PASSWORD}" + restart: "no" diff --git a/hpe_aruba_edgeconnect/tests/docker/fake_appliance/Dockerfile b/hpe_aruba_edgeconnect/tests/docker/fake_appliance/Dockerfile new file mode 100644 index 0000000000000..436c11ef43a97 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/docker/fake_appliance/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.13-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY tests/docker/fake_appliance/requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +RUN mkdir -p /app/data +COPY tests/fixtures/ /app/data/ +RUN cd /app/data && for d in */; do \ + name="${d%/}"; \ + tar -czf "${name}.tgz" -C /app/data "${name}" && rm -rf "${name}"; \ + done + +RUN mkdir -p /app/certs \ + && openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /app/certs/key.pem -out /app/certs/cert.pem \ + -days 365 -subj "/CN=localhost" + +COPY tests/docker/fake_appliance/app.py app.py + +EXPOSE 8444 + +CMD ["python", "app.py"] diff --git a/hpe_aruba_edgeconnect/tests/docker/fake_appliance/app.py b/hpe_aruba_edgeconnect/tests/docker/fake_appliance/app.py new file mode 100644 index 0000000000000..6c887772c1d9c --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/docker/fake_appliance/app.py @@ -0,0 +1,193 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +"""Fake HPE Aruba EdgeConnect appliance for E2E tests.""" + +import os +import ssl +import time + +from flask import Flask, jsonify, request, send_file + +app = Flask(__name__) + +MINUTE_STATS_INTERVAL = 60 +BASE_TIMESTAMP = 100000060 + +DATA_DIR = "/app/data" +CERT_FILE = "/app/certs/cert.pem" +KEY_FILE = "/app/certs/key.pem" + +APPLIANCE_USERNAME = os.environ.get("APPLIANCE_USERNAME", "admin") +APPLIANCE_PASSWORD = os.environ.get("APPLIANCE_PASSWORD", "") + + +@app.route("/rest/json/login", methods=["POST"]) +def login(): + data = request.get_json(silent=True) or {} + if data.get("user") != APPLIANCE_USERNAME or data.get("password") != APPLIANCE_PASSWORD: + return jsonify({"status": "unauthorized"}), 401 + return jsonify({"status": "ok"}) + + +@app.route("/rest/json/stats/minuteRange") +def minute_range(): + app.config["minute_counter"] = app.config.get("minute_counter", 0) + 1 + newest = BASE_TIMESTAMP + app.config["minute_counter"] + return jsonify({"newest": str(newest)}) + + +@app.route("/rest/json/stats/minuteStats/") +def minute_stats(filename: str): + archive = os.path.join(DATA_DIR, filename) + if not os.path.isfile(archive): + # Serve the canonical fixture for any timestamp the check requests + archive = os.path.join(DATA_DIR, f"st2-{BASE_TIMESTAMP}.tgz") + return send_file(archive, mimetype="application/gzip") + + +@app.route("/rest/json/networkInterfaces") +def network_interfaces(): + return jsonify( + { + "ifInfo": [ + {"ifname": "wan0", "admin": 1, "oper": 1, "speed": "1000Mb/s (auto)"}, + ] + } + ) + + +@app.route("/rest/json/cpustat") +def cpu_stats(): + timestamp = BASE_TIMESTAMP * 1000 + return jsonify( + { + "latestTimestamp": timestamp, + "data": [ + { + str(timestamp): [ + { + "cpu_number": "ALL", + "pIdle": "50.00", + "pUser": "30.00", + "pSys": "15.00", + "pIRQ": "3.00", + "pNice": "2.00", + }, + ], + }, + ], + } + ) + + +@app.route("/rest/json/memory") +def memory_stats(): + return jsonify( + { + "total": 3945080, + "free": 770848, + "buffers": 2516, + "cached": 729568, + "used": 3174232, + } + ) + + +@app.route("/rest/json/diskUsage") +def disk_usage(): + return jsonify( + { + "/dev": {"1k-blocks": 1965848, "used": 0, "available": 1965848, "usedpercent": 0, "filesystem": "none"}, + "/": { + "1k-blocks": 6126976, + "used": 1193348, + "available": 4619060, + "usedpercent": 21, + "filesystem": "/dev/disk/by-label/ROOT_1", + }, + "/var": { + "1k-blocks": 42030588, + "used": 4328968, + "available": 35553256, + "usedpercent": 11, + "filesystem": "/root/dev/disk/by-label/VAR", + }, + "/boot": { + "1k-blocks": 999288, + "used": 31676, + "available": 915188, + "usedpercent": 4, + "filesystem": "/dev/sda5", + }, + "/bootmgr": { + "1k-blocks": 999320, + "used": 3268, + "available": 943624, + "usedpercent": 1, + "filesystem": "/dev/sda1", + }, + "/config": { + "1k-blocks": 1015700, + "used": 1632, + "available": 961640, + "usedpercent": 1, + "filesystem": "/dev/sda3", + }, + "/run": {"1k-blocks": 1972540, "used": 4776, "available": 1967764, "usedpercent": 1, "filesystem": "tmpfs"}, + "/var/volatile": { + "1k-blocks": 1972540, + "used": 2384, + "available": 1970156, + "usedpercent": 1, + "filesystem": "tmpfs", + }, + } + ) + + +@app.route("/rest/json/alarm") +def alarms(): + return jsonify({"outstanding": []}) + + +@app.route("/rest/json/systemInfo") +def system_info(): + return jsonify( + { + "hostName": "FakeAppliance01", + "applianceid": 1, + "model": "EC-V 209005002001 Rev 102786", + "modelShort": "EC-V", + "platform": "VMware", + "status": "Normal", + "uptime": 86400000, + "uptimeString": "1d 0h 0m 0s", + "release": "ECOS 9.5.2.1_102786", + "releaseWithoutPrefix": "9.5.2.1_102786", + "serial": "00-00-00-02-F4-6D", + "uuid": "3dbcbf55-33e0-418f-b98c-3626f98cb0da", + "deploymentMode": "router", + "biosVersion": "6.00", + "alarmSummary": { + "num_cleared": 0, + "num_critical": 0, + "num_major": 0, + "num_minor": 0, + "num_outstanding": 0, + "num_warning": 0, + }, + } + ) + + +@app.route("/health") +def health(): + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + app.config["start_time"] = time.time() + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERT_FILE, KEY_FILE) + app.run(host="0.0.0.0", port=8444, ssl_context=ctx) diff --git a/hpe_aruba_edgeconnect/tests/docker/fake_appliance/requirements.txt b/hpe_aruba_edgeconnect/tests/docker/fake_appliance/requirements.txt new file mode 100644 index 0000000000000..dbcbaf7138ed0 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/docker/fake_appliance/requirements.txt @@ -0,0 +1 @@ +flask==3.1.0 diff --git a/hpe_aruba_edgeconnect/tests/docker/fake_orch/Dockerfile b/hpe_aruba_edgeconnect/tests/docker/fake_orch/Dockerfile new file mode 100644 index 0000000000000..f5daa72ce5841 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/docker/fake_orch/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.13-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY tests/docker/fake_orch/requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +RUN mkdir -p /app/certs \ + && openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /app/certs/key.pem -out /app/certs/cert.pem \ + -days 365 -subj "/CN=localhost" + +COPY tests/docker/fake_orch/app.py app.py + +EXPOSE 8443 + +CMD ["python", "app.py"] diff --git a/hpe_aruba_edgeconnect/tests/docker/fake_orch/app.py b/hpe_aruba_edgeconnect/tests/docker/fake_orch/app.py new file mode 100644 index 0000000000000..9e97a7406a33c --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/docker/fake_orch/app.py @@ -0,0 +1,102 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +"""Fake HPE Aruba EdgeConnect orchestrator for E2E tests.""" + +import os +import ssl + +from flask import Flask, jsonify, request + +app = Flask(__name__) + +CERT_FILE = "/app/certs/cert.pem" +KEY_FILE = "/app/certs/key.pem" + +ORCH_USERNAME = os.environ.get("ORCH_USERNAME", "admin") +ORCH_PASSWORD = os.environ.get("ORCH_PASSWORD", "") + +APPLIANCE_IP = os.environ.get("APPLIANCE_IP", "172.16.3.21") +PEER_NEWYORK_IP = "10.0.0.2" +PEER_SANFRAN_IP = "10.0.0.3" + + +def _appliance(ip, ne_pk, host_name, site, startup_time=None): + return { + "id": ne_pk, + "uuid": "19dde6b0-e971-4cbe-8714-c42c29657a2a", + "networkRole": "0", + "site": site, + "sitePriority": 0, + "userName": "admin", + "password": None, + "groupId": "2.Network", + "IP": ip, + "webProtocolType": 3, + "serial": "SN001", + "hasUnsavedChanges": False, + "rebootRequired": False, + "model": "EC-V", + "hardwareRevision": "209005002001 Rev 102786", + "hostName": host_name, + "applianceId": 182356, + "platform": "VMware", + "mode": "router", + "bypass": False, + "softwareVersion": "9.3.1", + "startupTime": startup_time, + "webProtocol": "BOTH", + "systemBandwidth": 300000, + "state": 1, + "dynamicUuid": "2f938d31-8eb6-428d-9a16-208aec647d3d", + "portalObjectId": "69811624fb692e7082aff5ca", + "discoveredFrom": 2, + "reachabilityChannel": 2, + "preconfigStatus": None, + "suricataVersion": "6.0.10", + "signatureFamily": "5.x", + "ip": ip, + "nePk": ne_pk, + } + + +APPLIANCE_LIST = [ + _appliance(APPLIANCE_IP, "1.NE", "SydneySP01", "SYD", startup_time=86400), + _appliance(PEER_NEWYORK_IP, "4.NE", "NewYorkSP01", "NYC"), + _appliance(PEER_SANFRAN_IP, "5.NE", "SanFranSP02", "SFO"), +] + + +@app.route("/gms/rest/authentication/login", methods=["POST"]) +def login(): + data = request.get_json(silent=True) or {} + if data.get("user") != ORCH_USERNAME or data.get("password") != ORCH_PASSWORD: + return jsonify({"status": "unauthorized"}), 401 + return jsonify({"status": "ok"}) + + +@app.route("/gms/rest/appliance") +def appliances(): + return jsonify(APPLIANCE_LIST) + + +@app.route("/gms/rest/gms/overlays/config") +def overlay_config(): + return jsonify( + [ + {"id": 0, "name": "business"}, + {"id": 2, "name": "BulkData", "trafficClass": "2"}, + {"id": 4, "name": "RealTime", "trafficClass": "4"}, + ] + ) + + +@app.route("/health") +def health(): + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERT_FILE, KEY_FILE) + app.run(host="0.0.0.0", port=8443, ssl_context=ctx) diff --git a/hpe_aruba_edgeconnect/tests/docker/fake_orch/requirements.txt b/hpe_aruba_edgeconnect/tests/docker/fake_orch/requirements.txt new file mode 100644 index 0000000000000..dbcbaf7138ed0 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/docker/fake_orch/requirements.txt @@ -0,0 +1 @@ +flask==3.1.0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/appperf_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/appperf_v2.txt new file mode 100644 index 0000000000000..8435434ff9367 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/appperf_v2.txt @@ -0,0 +1,2 @@ +3.NE,0,microsoft,bondedTunnel_16,Backhaul,5.2,12.3,17.5,8.4,9.1,60,0,0,0,0,0,0,0,0,1774447560 +3.NE,1,salesforce,pass-through-unshaped,Passthrough,3.1,7.8,10.9,4.5,5.2,45,0,0,0,0,0,0,0,0,1774447560 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp.csv new file mode 100644 index 0000000000000..c63e41b6613b0 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp.csv @@ -0,0 +1,6 @@ +dscp,traftype,bytes_wtx,bytes_wrx,bytes_ltx,bytes_lrx,pkts_wtx,pkts_wrx,pkts_ltx,pkts_lrx,comp_l2w,comp_w2l,comp_noohead_l2w,comp_noohead_w2l +be,pass-through,328,698,698,328,4,4,4,4,0,0,0,0 +be,pass-through-unshaped,37524,27376,27376,37524,177,118,118,177,0,0,0,0 +be,all traffic,37852,28074,28074,37852,181,122,122,181,0,0,0,0 +cs3,pass-through-unshaped,0,13688,13688,0,0,59,59,0,0,0,0,0 +cs3,all traffic,0,13688,13688,0,0,59,59,0,0,0,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp_peak.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp_peak.csv new file mode 100644 index 0000000000000..a66ba1c7e3a83 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/dscp_peak.csv @@ -0,0 +1,6 @@ +dscp,traftype,bytes_wtx,bytes_wtx_ts,bytes_wrx,bytes_wrx_ts,bytes_ltx,bytes_ltx_ts,bytes_lrx,bytes_lrx_ts,pkts_wtx,pkts_wtx_ts,pkts_wrx,pkts_wrx_ts,pkts_ltx,pkts_ltx_ts,pkts_lrx,pkts_lrx_ts,comp_l2w,comp_l2w_ts,comp_w2l,comp_w2l_ts,comp_noohead_l2w,comp_noohead_l2w_ts,comp_noohead_w2l,comp_noohead_w2l_ts +be,pass-through,328,1774447518,698,1774447518,698,1774447518,328,1774447518,4,1774447518,4,1774447518,4,1774447518,4,1774447518,0,0,0,0,0,0,0,0 +be,pass-through-unshaped,636,1774447501,464,1774447501,464,1774447501,636,1774447501,3,1774447501,2,1774447501,2,1774447501,3,1774447501,0,0,0,0,0,0,0,0 +be,all traffic,964,1774447518,1162,1774447518,1162,1774447518,964,1774447518,7,1774447518,6,1774447518,6,1774447518,7,1774447518,0,0,0,0,0,0,0,0 +cs3,pass-through-unshaped,0,0,232,1774447501,232,1774447501,0,0,0,0,1,1774447501,1,1774447501,0,0,0,0,0,0,0,0,0,0 +cs3,all traffic,0,0,232,1774447501,232,1774447501,0,0,0,0,1,1774447501,1,1774447501,0,0,0,0,0,0,0,0,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface.csv new file mode 100644 index 0000000000000..f04cbe59369b2 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface.csv @@ -0,0 +1,9 @@ +ifname,traftype,bytes_tx,bytes_rx,pkts_tx,pkts_rx,ohead_bytes_tx,ohead_bytes_rx,ohead_pkts_tx,ohead_pkts_rx,fwdrops_bytes_tx,fwdrops_bytes_rx,fwdrops_pkts_tx,fwdrops_pkts_rx,max_bw_tx,max_bw_rx +wan1,optimized traffic,13232,26320,41,79,13232,26320,41,79,0,0,0,0,100000,100000 +wan1,all traffic,13232,26320,41,79,13232,26320,41,79,0,0,0,0,100000,100000 +lan0,pass-through,698,328,4,4,0,0,0,0,0,0,0,0,100000,100000 +lan0,all traffic,698,328,4,4,0,0,0,0,0,0,0,0,0,0 +wan0,optimized traffic,12672,25536,36,74,12672,25536,36,74,0,0,0,0,100000,100000 +wan0,pass-through,328,698,4,4,0,0,0,0,0,0,0,0,100000,100000 +wan0,pass-through-unshaped,78588,41064,354,177,0,0,0,0,0,0,0,0,100000,100000 +wan0,all traffic,91588,67298,394,255,12672,25536,36,74,0,0,0,0,100000,100000 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_overlay.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_overlay.csv new file mode 100644 index 0000000000000..5952b00fa7871 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_overlay.csv @@ -0,0 +1,11 @@ +ifname,label,overlayid,tuntype,bytes_tx,bytes_rx,pkts_tx,pkts_rx,ohead_bytes_tx,ohead_bytes_rx,ohead_pkts_tx,ohead_pkts_rx,max_bw_tx,max_bw_rx,bytes_tx_max,bytes_tx_max_ts,bytes_rx_max,bytes_rx_max_ts,pkts_tx_max,pkts_tx_max_ts,pkts_rx_max,pkts_rx_max_ts,ohead_bytes_tx_max,ohead_bytes_tx_max_ts,ohead_bytes_rx_max,ohead_bytes_rx_max_ts,ohead_pkts_tx_max,ohead_pkts_tx_max_ts,ohead_pkts_rx_max,ohead_pkts_rx_max_ts +wan1,1,0,1,13008,26208,39,78,13008,26208,39,78,100000,100000,704,1774447501,2112,1774447536,2,1774447501,6,1774447505,704,1774447501,2112,1774447536,2,1774447501,6,1774447505 +wan1,1,3,0,224,112,2,1,224,112,2,1,100000,100000,224,1774447505,112,1774447505,2,1774447505,1,1774447505,224,1774447505,112,1774447505,2,1774447505,1,1774447505 +lan0,5,2,2,0,188,0,2,0,0,0,0,0,0,0,0,188,1774447518,0,0,2,1774447518,0,0,0,0,0,0,0,0 +lan0,5,4,2,0,140,0,2,0,0,0,0,0,0,0,0,140,1774447518,0,0,2,1774447518,0,0,0,0,0,0,0,0 +wan0,2,0,1,12672,25344,36,72,12672,25344,36,72,100000,100000,1056,1774447546,2816,1774447546,3,1774447546,8,1774447546,1056,1774447546,2816,1774447546,3,1774447546,8,1774447546 +wan0,2,0,2,37524,41064,177,177,0,0,0,0,100000,100000,636,1774447501,696,1774447501,3,1774447501,3,1774447501,0,0,0,0,0,0,0,0 +wan0,2,1,0,0,96,0,1,0,96,0,1,100000,100000,0,0,96,1774447520,0,0,1,1774447520,0,0,96,1774447520,0,0,1,1774447520 +wan0,2,2,0,0,96,0,1,0,96,0,1,100000,100000,0,0,96,1774447523,0,0,1,1774447523,0,0,96,1774447523,0,0,1,1774447523 +wan0,2,2,2,188,416,2,2,0,0,0,0,100000,100000,188,1774447518,416,1774447518,2,1774447518,2,1774447518,0,0,0,0,0,0,0,0 +wan0,2,4,2,140,282,2,2,0,0,0,0,100000,100000,140,1774447518,282,1774447518,2,1774447518,2,1774447518,0,0,0,0,0,0,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_peak.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_peak.csv new file mode 100644 index 0000000000000..aff8ce68cacec --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/interface_peak.csv @@ -0,0 +1,9 @@ +ifname,traftype,bytes_tx,bytes_tx_ts,bytes_rx,bytes_rx_ts,pkts_tx,pkts_tx_ts,pkts_rx,pkts_rx_ts,ohead_bytes_tx,ohead_bytes_tx_ts,ohead_bytes_rx,ohead_bytes_rx_ts,ohead_pkts_tx,ohead_pkts_tx_ts,ohead_pkts_rx,ohead_pkts_rx_ts,fwdrops_bytes_tx,fwdrops_bytes_tx_ts,fwdrops_bytes_rx,fwdrops_bytes_rx_ts,fwdrops_pkts_tx,fwdrops_pkts_tx_ts,fwdrops_pkts_rx,fwdrops_pkts_rx_ts,max_bw_tx,max_bw_tx_ts,max_bw_rx,max_bw_rx_ts +wan1,optimized traffic,704,1774447501,2112,1774447536,4,1774447505,7,1774447505,704,1774447501,2112,1774447536,4,1774447505,7,1774447505,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433218 +wan1,all traffic,704,1774447501,2112,1774447536,4,1774447505,7,1774447505,704,1774447501,2112,1774447536,4,1774447505,7,1774447505,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433218 +lan0,pass-through,698,1774447518,328,1774447518,4,1774447518,4,1774447518,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433221 +lan0,all traffic,698,1774447518,328,1774447518,4,1774447518,4,1774447518,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433221 +wan0,optimized traffic,1056,1774447546,2464,1774447546,3,1774447546,7,1774447546,1056,1774447546,2464,1774447546,3,1774447546,7,1774447546,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433222 +wan0,pass-through,328,1774447518,698,1774447518,4,1774447518,4,1774447518,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433222 +wan0,pass-through-unshaped,1332,1774447501,696,1774447501,6,1774447501,3,1774447501,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433222 +wan0,all traffic,2388,1774447546,3160,1774447546,10,1774447518,10,1774447546,1056,1774447546,2464,1774447546,3,1774447546,7,1774447546,0,0,0,0,0,0,0,0,139678991266796,139678781005488,139678991266368,139676631433222 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/jitter.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/jitter.csv new file mode 100644 index 0000000000000..35beb3185fd91 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/jitter.csv @@ -0,0 +1,32 @@ +tunnel, jitter, peak_jitter, timestamp +pass-through, 0, 0, 0 +pass-through-unshaped, 0, 0, 0 +passThrough_1, 0, 0, 0 +passThrough_10, 0, 0, 0 +passThrough_11, 0, 0, 0 +passThrough_2, 0, 0, 0 +passThrough_3, 0, 0, 0 +passThrough_4, 0, 0, 0 +passThrough_5, 0, 0, 0 +passThrough_6, 0, 0, 0 +passThrough_7, 0, 0, 0 +passThrough_8, 0, 0, 0 +passThrough_9, 0, 0, 0 +tunnel_12, 0, 0, 0 +tunnel_13, 0, 0, 0 +bondedTunnel_14, 500, 5, 1774447501 +bondedTunnel_15, 1400, 14, 1774447501 +bondedTunnel_16, 100, 1, 1774447501 +bondedTunnel_17, 100, 1, 1774447501 +tunnel_18, 600, 6, 1774447501 +tunnel_19, 0, 0, 0 +tunnel_20, 0, 0, 0 +tunnel_21, 0, 0, 0 +bondedTunnel_22, 500, 5, 1774447501 +bondedTunnel_23, 0, 0, 0 +bondedTunnel_24, 0, 0, 0 +bondedTunnel_25, 0, 0, 0 +bondedTunnel_26, 0, 0, 0 +bondedTunnel_27, 0, 0, 0 +bondedTunnel_28, 0, 0, 0 +bondedTunnel_29, 500, 5, 1774447501 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/mos.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/mos.csv new file mode 100644 index 0000000000000..08df6c987ffab --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/mos.csv @@ -0,0 +1,32 @@ +tunnel, mos_postfec, min_mos_postfec, min_mos_postfec_ts, mos_prefec, min_mos_prefec, min_mos_prefec_ts +pass-through, 0.00, 0.00, 0, 0.00, 0.00, 0 +pass-through-unshaped, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_1, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_10, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_11, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_2, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_3, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_4, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_5, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_6, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_7, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_8, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_9, 0.00, 0.00, 0, 0.00, 0.00, 0 +tunnel_12, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +tunnel_13, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_14, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_15, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_16, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_17, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +tunnel_18, 4.00, 4.00, 1774447513, 4.00, 4.00, 1774447513 +tunnel_19, 4.00, 4.00, 1774447513, 4.00, 4.00, 1774447513 +tunnel_20, 4.00, 4.00, 1774447503, 4.00, 4.00, 1774447503 +tunnel_21, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_22, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_23, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_24, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_25, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_26, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_27, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_28, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 +bondedTunnel_29, 4.00, 4.00, 1774447501, 4.00, 4.00, 1774447501 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/probe_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/probe_v2.txt new file mode 100644 index 0000000000000..4855a3b86f356 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/probe_v2.txt @@ -0,0 +1,12 @@ +3.NE,0,om_passThrough_6,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3717,3064,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,1,om_passThrough_6,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2817,2153,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,2,om_passThrough_6,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3485,2843,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,3,om_passThrough_7,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3717,3064,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,4,om_passThrough_7,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2817,2153,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,5,om_passThrough_7,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3485,2843,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,6,om_passThrough_3,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3717,3064,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,7,om_passThrough_3,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2817,2153,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,8,om_passThrough_3,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3485,2843,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,9,om_passThrough_9,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3717,3064,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,10,om_passThrough_9,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2817,2153,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 +3.NE,11,om_passThrough_9,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3485,2843,0,0,0,0,59,59,0,0,0,0,0,0,0,0,1774447560 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/shaper.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/shaper.csv new file mode 100644 index 0000000000000..971b1ff482803 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/shaper.csv @@ -0,0 +1,5 @@ +traffic_class, direction, total_bytes, shaped_bytes, shaped_packets, total_wait, total_wait_count, qos_drops, other_drops +2,0,188,188,2,0,2,0,0 +2,1,12884,12884,61,0,61,0,0 +4,0,140,140,2,0,2,0,0 +4,1,25258,25258,120,0,120,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_availability_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_availability_v2.txt new file mode 100644 index 0000000000000..59ce535e7ad5e --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_availability_v2.txt @@ -0,0 +1,31 @@ +3.NE,pass-through,pass-through,0,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,pass-through-unshaped,pass-through-unshaped,0,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_1,Passthrough_Data_lan0,0,1,2,00000000000000000000000000000000,0,0,0,0,1,Data,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_10,Passthrough_MPLS1_wan1,0,1,2,00000000000000000000000000000000,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_11,Passthrough_INET1_wan0,0,1,2,00000000000000000000000000000000,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_2,Passthrough_MPLS1_RealTime,1,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_3,Passthrough_INET1_RealTime,1,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_RealTime_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_4,Passthrough_MPLS1_CriticalApps,2,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_5,Passthrough_MPLS1_BulkApps,3,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_6,Passthrough_INET1_CriticalApps,2,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_CriticalApps_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_7,Passthrough_INET1_BulkApps,3,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_BulkApps_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_8,Passthrough_MPLS1_DefaultOverlay,4,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_9,Passthrough_INET1_DefaultOverlay,4,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_DefaultOverlay_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,tunnel_12,to_NewYorkSP01_MPLS1-MPLS1,0,0,1,3d4d299f2d30ca90c3283cc6409b04eb,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,tunnel_13,to_NewYorkSP01_INET1-INET1,0,0,1,15875bda2e479ae57dbab202656c0a10,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_14,to_NewYorkSP01_DefaultOverlay,4,0,0,00dd04213acc688fb1f94f0f35dd1118,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_15,to_NewYorkSP01_BulkApps,3,0,0,2a0d71412033475810fb413acf55960e,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_16,to_NewYorkSP01_CriticalApps,2,0,0,64db1a819c167ef5786d5bebabb7e681,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_17,to_NewYorkSP01_RealTime,1,0,0,c67de1d41d24067d0404da27fbd05574,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,tunnel_18,to_SydneySP01_INET1-INET1,0,0,1,64a0d7865d0067ab26ccde85ff3d0bcb,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,tunnel_19,to_SydneySP01_MPLS1-MPLS1,0,0,1,b7988dcaf9c3ed4483f5f690303a7247,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,tunnel_20,to_SanFranSP02_INET1-INET1,0,0,1,9bf32ee020aa7d38abf87fb4a366e126,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,tunnel_21,to_SanFranSP02_MPLS1-MPLS1,0,0,1,515fa7659491f9b5fbdeeb0c24e11330,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_22,to_SanFranSP02_RealTime,1,0,0,27c2202a76bcef413a9ef5b276ba9cc6,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_23,to_SydneySP01_DefaultOverlay,4,0,0,91b9acd13734384d6d8c84c441c4a428,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_24,to_SydneySP01_RealTime,1,0,0,e4dd914dd2e5dd3faa5b28dfbdfa25db,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_25,to_SydneySP01_CriticalApps,2,0,0,e23a62fc5fcdbbf9cb885fadc4b77580,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_26,to_SydneySP01_BulkApps,3,0,0,0b59da7eb69812150a8f5a911a5fbc6a,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_27,to_SanFranSP02_BulkApps,3,0,0,7ee606393b0eca78778c2d82edd0fcd3,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_28,to_SanFranSP02_DefaultOverlay,4,0,0,cbb8dd0025057d8744f813e19801078d,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,bondedTunnel_29,to_SanFranSP02_CriticalApps,2,0,0,0865b6310dd706cacd7bcd32ba3b96f7,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_peak.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_peak.csv new file mode 100644 index 0000000000000..54a9742c3ce49 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_peak.csv @@ -0,0 +1,34 @@ +tunname,bytes_wtx,bytes_wtx_ts,bytes_wrx,bytes_wrx_ts,bytes_ltx,bytes_ltx_ts,bytes_lrx,bytes_lrx_ts,pkts_wtx,pkts_wtx_ts,pkts_wrx,pkts_wrx_ts,pkts_ltx,pkts_ltx_ts,pkts_lrx,pkts_lrx_ts,comp_l2w,comp_l2w_ts,comp_w2l,comp_w2l_ts,comp_noohead_l2w,comp_noohead_l2w_ts,comp_noohead_w2l,comp_noohead_w2l_ts,latency_s,latency_s_ts,latency_min_s,latency_min_s_ts,flow_ext_tcp,flow_ext_tcp_ts,flow_ext_tcpacc,flow_ext_tcpacc_ts,flow_ext_non,flow_ext_non_ts,flow_add,flow_add_ts,flow_rem,flow_rem_ts,loss_prefec_wrx_pkts,loss_prefec_wrx_pkts_ts,loss_postfec_wrx_pkts,loss_postfec_wrx_pkts_ts,loss_prefec_wrx_pct,loss_prefec_wrx_pct_ts,loss_postfec_wrx_pct,loss_postfec_wrx_pct_ts,ooo_prepoc_wrx_pkts,ooo_prepoc_wrx_pkts_ts,ooo_postpoc_wrx_pkts,ooo_postpoc_wrx_pkts_ts,ooo_prepoc_wrx_pct,ooo_prepoc_wrx_pct_ts,ooo_postpoc_wrx_pct,ooo_postpoc_wrx_pct_ts,ohead_wrx_pkts,ohead_wrx_pkts_ts,ohead_wtx_pkts,ohead_wtx_pkts_ts,ohead_wrx_bytes,ohead_wrx_bytes_ts,ohead_wtx_bytes,ohead_wtx_bytes_ts,ohead_wrx_hdr_bytes,ohead_wrx_hdr_bytes_ts,ohead_wtx_hdr_bytes,ohead_wtx_hdr_bytes_ts,bw_util_pct,bw_util_pct_ts +pass-through,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +pass-through-unshaped,636,1774447501,1160,1774447502,1160,1774447502,636,1774447501,3,1774447501,5,1774447502,5,1774447502,3,1774447501,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,1774447501,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,1774447501,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_6,188,1774447518,416,1774447518,416,1774447518,188,1774447518,2,1774447518,2,1774447518,2,1774447518,2,1774447518,100,1774447518,100,1774447518,100,1774447518,76,1774447518,0,0,0,0,0,0,0,0,0,0,2,1774447518,12,1774447518,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_9,140,1774447518,282,1774447518,282,1774447518,140,1774447518,2,1774447518,2,1774447518,2,1774447518,2,1774447518,100,1774447518,100,1774447518,100,1774447518,65,1774447518,0,0,0,0,5436,1774447501,0,0,0,0,2,1774447518,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,147,1774447516,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_13,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,147,1774447516,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,153,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,142,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_17,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_18,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,87,1774447514,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_19,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,87,1774447514,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_20,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,20,1774447503,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_21,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,1774447513,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_23,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,52,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_24,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,53,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,53,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_26,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,53,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_28,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_29,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,1774447501,58,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +all traffic,636,1774447501,1160,1774447502,1160,1774447502,636,1774447501,3,1774447501,5,1774447502,5,1774447502,3,1774447501,100,1774447518,100,1774447518,100,1774447518,76,1774447518,153,1774447501,0,0,5436,1774447501,0,0,3,1774447501,2,1774447518,12,1774447518,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +optimized traffic,188,1774447518,416,1774447518,416,1774447518,188,1774447518,2,1774447518,2,1774447518,2,1774447518,2,1774447518,100,1774447518,100,1774447518,100,1774447518,76,1774447518,153,1774447501,0,0,5436,1774447501,0,0,3,1774447501,2,1774447518,12,1774447518,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_v2.txt new file mode 100644 index 0000000000000..ae9334c89ae05 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000000/tunnel_v2.txt @@ -0,0 +1,33 @@ +3.NE,pass-through,pass-through,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,pass-through-unshaped,pass-through-unshaped,0,1,00000000000000000000000000000000,37524,41528,41528,37524,177,179,179,177,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4956,0,4908642120631577485,4940730267976592270,4940730267976592270,4908642120631577485,4629700418711317389,4656722016475540366,4656722016475540366,4629700418711317389,0,0,0,4629700418711317389,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_1,Passthrough_Data_lan0,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_10,Passthrough_MPLS1_wan1,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_11,Passthrough_INET1_wan0,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4629700418711317389,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_2,Passthrough_MPLS1_RealTime,1,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_3,Passthrough_INET1_RealTime,1,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_4,Passthrough_MPLS1_CriticalApps,2,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_5,Passthrough_MPLS1_BulkApps,3,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_6,Passthrough_INET1_CriticalApps,2,1,00000000000000000000000000000000,188,416,416,188,2,2,2,2,0,0,0,0,0,2,12,0,0,0,0,0,0,0,0,0,0,0,0,0,56,0,4844747300918258590,4886405597471435678,4886405597471435678,4844747300918258590,4611686020201835422,4611686020201835422,4611686020201835422,4611686020201835422,0,0,0,0,4611686020201835422,4701758012749245342,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_7,Passthrough_INET1_BulkApps,3,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_8,Passthrough_MPLS1_DefaultOverlay,4,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,passThrough_9,Passthrough_INET1_DefaultOverlay,4,1,00000000000000000000000000000000,140,282,282,140,2,2,2,2,0,0,5436,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,56,0,4831236502036147102,4867546774031821726,4867546774031821726,4831236502036147102,4611686020201835422,4611686020201835422,4611686020201835422,4611686020201835422,0,5019789552060197773,0,0,4611686020201835422,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1774447560 +3.NE,tunnel_12,to_NewYorkSP01_MPLS1-MPLS1,0,0,3d4d299f2d30ca90c3283cc6409b04eb,0,0,0,0,0,0,0,0,141,138,0,0,0,0,0,0,0,0,0,0,0,0,0,12,12,4224,4224,0,0,0,0,0,0,0,0,0,0,0,4833206826873121692,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,tunnel_13,to_NewYorkSP01_INET1-INET1,0,0,15875bda2e479ae57dbab202656c0a10,0,0,0,0,0,0,0,0,140,138,0,0,0,0,0,0,0,0,0,0,0,0,0,12,12,4224,4224,0,0,0,0,0,0,0,0,0,0,0,4833206826873121692,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_14,to_NewYorkSP01_DefaultOverlay,4,0,00dd04213acc688fb1f94f0f35dd1118,0,0,0,0,0,0,0,0,153,153,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4834895676733385613,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,500,4656722016475540365,1774447560 +3.NE,bondedTunnel_15,to_NewYorkSP01_BulkApps,3,0,2a0d71412033475810fb413acf55960e,0,0,0,0,0,0,0,0,142,142,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4831799451989568397,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,1400,4710765212003986317,1774447560 +3.NE,bondedTunnel_16,to_NewYorkSP01_CriticalApps,2,0,64db1a819c167ef5786d5bebabb7e681,0,0,0,0,0,0,0,0,139,139,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4830955027059436429,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,100,4575657223182871437,1774447560 +3.NE,bondedTunnel_17,to_NewYorkSP01_RealTime,1,0,c67de1d41d24067d0404da27fbd05574,0,0,0,0,0,0,0,0,139,139,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4830955027059436429,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,100,4575657223182871437,1774447560 +3.NE,tunnel_18,to_SydneySP01_INET1-INET1,0,0,64a0d7865d0067ab26ccde85ff3d0bcb,0,0,0,0,0,0,0,0,63,53,0,0,0,0,0,0,0,0,0,0,0,0,0,12,12,4224,4224,0,0,0,0,0,0,0,0,0,0,0,4804777854225345434,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750425,4884153797657750425,600,4665729215730281357,1774447560 +3.NE,tunnel_19,to_SydneySP01_MPLS1-MPLS1,0,0,b7988dcaf9c3ed4483f5f690303a7247,0,0,0,0,0,0,0,0,63,53,0,0,0,0,0,0,0,0,0,0,0,0,0,16,17,4768,4784,0,0,0,0,0,0,0,0,0,0,0,4804777854225345434,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750425,4884153797657750425,0,0,1774447560 +3.NE,tunnel_20,to_SanFranSP02_INET1-INET1,0,0,9bf32ee020aa7d38abf87fb4a366e126,0,0,0,0,0,0,0,0,9,2,0,0,0,0,0,0,0,0,0,0,0,0,0,14,12,4416,4224,0,0,0,0,0,0,0,0,0,0,0,4728779610513468303,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750415,4884153797657750415,0,0,1774447560 +3.NE,tunnel_21,to_SanFranSP02_MPLS1-MPLS1,0,0,515fa7659491f9b5fbdeeb0c24e11330,0,0,0,0,0,0,0,0,4,2,0,0,0,0,0,0,0,0,0,0,0,0,0,12,12,4224,4224,0,0,0,0,0,0,0,0,0,0,0,4674736414985022361,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_22,to_SanFranSP02_RealTime,1,0,27c2202a76bcef413a9ef5b276ba9cc6,0,0,0,0,0,0,0,0,9,9,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,96,0,0,0,0,0,0,0,0,0,0,0,0,4688247213867133837,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,500,4656722016475540365,1774447560 +3.NE,bondedTunnel_23,to_SydneySP01_DefaultOverlay,4,0,91b9acd13734384d6d8c84c441c4a428,0,0,0,0,0,0,0,0,52,52,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4778319206414543757,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_24,to_SydneySP01_RealTime,1,0,e4dd914dd2e5dd3faa5b28dfbdfa25db,0,0,0,0,0,0,0,0,53,53,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4779445106321386381,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_25,to_SydneySP01_CriticalApps,2,0,e23a62fc5fcdbbf9cb885fadc4b77580,0,0,0,0,0,0,0,0,53,53,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4779445106321386381,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_26,to_SydneySP01_BulkApps,3,0,0b59da7eb69812150a8f5a911a5fbc6a,0,0,0,0,0,0,0,0,52,52,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,112,224,0,0,0,0,0,0,0,0,0,0,0,4779445106321386381,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_27,to_SanFranSP02_BulkApps,3,0,7ee606393b0eca78778c2d82edd0fcd3,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4611686020201835405,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_28,to_SanFranSP02_DefaultOverlay,4,0,cbb8dd0025057d8744f813e19801078d,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4575657223182871437,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,0,0,1774447560 +3.NE,bondedTunnel_29,to_SanFranSP02_CriticalApps,2,0,0865b6310dd706cacd7bcd32ba3b96f7,0,0,0,0,0,0,0,0,9,9,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,96,0,0,0,0,0,0,0,0,0,0,0,0,4688247213867133837,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797657750413,4884153797657750413,500,4656722016475540365,1774447560 +3.NE,all traffic,all traffic,0,1,unknown,37852,42226,42226,37852,181,183,183,181,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,78,77,26080,25904,0,5068,0,4908642120631577485,4940730267976592270,4940730267976592270,4908642120631577485,4629700418711317389,4656722016475540366,4656722016475540366,4629700418711317389,4834895676733385613,5019789552060197773,0,4629700418711317389,4611686020201835422,4701758012749245342,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4710765212003986317,1774447560 +3.NE,optimized traffic,optimized traffic,0,1,unknown,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,78,77,26080,25904,0,0,0,4844747300918258590,4886405597471435678,4886405597471435678,4844747300918258590,4611686020201835422,4611686020201835422,4611686020201835422,4611686020201835422,4834895676733385613,5019789552060197773,0,4629700418711317389,4611686020201835422,4701758012749245342,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4710765212003986317,1774447560 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/appperf_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/appperf_v2.txt new file mode 100644 index 0000000000000..e9197e0f94913 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/appperf_v2.txt @@ -0,0 +1,2 @@ +3.NE,0,microsoft,bondedTunnel_16,Backhaul,5.0,12.1,17.1,8.2,8.9,60,0,0,0,0,0,0,0,0,1772622540 +3.NE,1,salesforce,pass-through-unshaped,Passthrough,3.3,8.0,11.3,4.7,5.4,45,0,0,0,0,0,0,0,0,1772622540 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp.csv new file mode 100644 index 0000000000000..eb12e2687ab2e --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp.csv @@ -0,0 +1,5 @@ +dscp,traftype,bytes_wtx,bytes_wrx,bytes_ltx,bytes_lrx,pkts_wtx,pkts_wrx,pkts_ltx,pkts_lrx,comp_l2w,comp_w2l,comp_noohead_l2w,comp_noohead_w2l +be,pass-through-unshaped,38160,27840,27840,38160,180,120,120,180,0,0,0,0 +be,all traffic,38160,27840,27840,38160,180,120,120,180,0,0,0,0 +cs3,pass-through-unshaped,0,13920,13920,0,0,60,60,0,0,0,0,0 +cs3,all traffic,0,13920,13920,0,0,60,60,0,0,0,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp_peak.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp_peak.csv new file mode 100644 index 0000000000000..be929d2a60ff5 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/dscp_peak.csv @@ -0,0 +1,5 @@ +dscp,traftype,bytes_wtx,bytes_wtx_ts,bytes_wrx,bytes_wrx_ts,bytes_ltx,bytes_ltx_ts,bytes_lrx,bytes_lrx_ts,pkts_wtx,pkts_wtx_ts,pkts_wrx,pkts_wrx_ts,pkts_ltx,pkts_ltx_ts,pkts_lrx,pkts_lrx_ts,comp_l2w,comp_l2w_ts,comp_w2l,comp_w2l_ts,comp_noohead_l2w,comp_noohead_l2w_ts,comp_noohead_w2l,comp_noohead_w2l_ts +be,pass-through-unshaped,636,1772622481,464,1772622481,464,1772622481,636,1772622481,3,1772622481,2,1772622481,2,1772622481,3,1772622481,0,0,0,0,0,0,0,0 +be,all traffic,636,1772622481,464,1772622481,464,1772622481,636,1772622481,3,1772622481,2,1772622481,2,1772622481,3,1772622481,0,0,0,0,0,0,0,0 +cs3,pass-through-unshaped,0,0,232,1772622481,232,1772622481,0,0,0,0,1,1772622481,1,1772622481,0,0,0,0,0,0,0,0,0,0 +cs3,all traffic,0,0,232,1772622481,232,1772622481,0,0,0,0,1,1772622481,1,1772622481,0,0,0,0,0,0,0,0,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface.csv new file mode 100644 index 0000000000000..0e0632ab43011 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface.csv @@ -0,0 +1,6 @@ +ifname,traftype,bytes_tx,bytes_rx,pkts_tx,pkts_rx,ohead_bytes_tx,ohead_bytes_rx,ohead_pkts_tx,ohead_pkts_rx,fwdrops_bytes_tx,fwdrops_bytes_rx,fwdrops_pkts_tx,fwdrops_pkts_rx,max_bw_tx,max_bw_rx +wan1,optimized traffic,15616,30768,58,113,15616,30768,58,113,0,0,0,0,0,0 +wan1,all traffic,15616,30768,58,113,15616,30768,58,113,0,0,0,0,100000,100000 +wan0,optimized traffic,13120,26848,40,86,13120,26848,40,86,0,0,0,0,0,0 +wan0,pass-through-unshaped,79920,41760,360,180,0,0,0,0,0,0,0,0,100000,100000 +wan0,all traffic,93040,68608,400,266,13120,26848,40,86,0,0,0,0,100000,100000 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_overlay.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_overlay.csv new file mode 100644 index 0000000000000..15be4a694ecae --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_overlay.csv @@ -0,0 +1,10 @@ +ifname,label,overlayid,tuntype,bytes_tx,bytes_rx,pkts_tx,pkts_rx,ohead_bytes_tx,ohead_bytes_rx,ohead_pkts_tx,ohead_pkts_rx,max_bw_tx,max_bw_rx,bytes_tx_max,bytes_tx_max_ts,bytes_rx_max,bytes_rx_max_ts,pkts_tx_max,pkts_tx_max_ts,pkts_rx_max,pkts_rx_max_ts,ohead_bytes_tx_max,ohead_bytes_tx_max_ts,ohead_bytes_rx_max,ohead_bytes_rx_max_ts,ohead_pkts_tx_max,ohead_pkts_tx_max_ts,ohead_pkts_rx_max,ohead_pkts_rx_max_ts +wan1,1,0,1,15056,29984,53,106,15056,29984,53,106,100000,100000,736,1772622490,1664,1772622515,4,1772622511,10,1772622515,736,1772622490,1664,1772622515,4,1772622511,10,1772622515 +wan1,1,1,0,112,224,1,2,112,224,1,2,100000,100000,112,1772622489,224,1772622489,1,1772622489,2,1772622489,112,1772622489,224,1772622489,1,1772622489,2,1772622489 +wan1,1,2,0,112,224,1,2,112,224,1,2,100000,100000,112,1772622489,224,1772622489,1,1772622489,2,1772622489,112,1772622489,224,1772622489,1,1772622489,2,1772622489 +wan1,1,3,0,336,336,3,3,336,336,3,3,100000,100000,224,1772622511,224,1772622494,2,1772622511,2,1772622494,224,1772622511,224,1772622494,2,1772622511,2,1772622494 +wan0,2,0,1,12784,25792,37,76,12784,25792,37,76,100000,100000,464,1772622514,1152,1772622514,2,1772622514,6,1772622514,464,1772622514,1152,1772622514,2,1772622514,6,1772622514 +wan0,2,0,2,38160,41760,180,180,0,0,0,0,100000,100000,636,1772622481,696,1772622481,3,1772622481,3,1772622481,0,0,0,0,0,0,0,0 +wan0,2,1,0,112,416,1,4,112,416,1,4,100000,100000,112,1772622489,224,1772622489,1,1772622489,2,1772622489,112,1772622489,224,1772622489,1,1772622489,2,1772622489 +wan0,2,2,0,112,416,1,4,112,416,1,4,100000,100000,112,1772622489,224,1772622489,1,1772622489,2,1772622489,112,1772622489,224,1772622489,1,1772622489,2,1772622489 +wan0,2,4,0,112,224,1,2,112,224,1,2,100000,100000,112,1772622490,224,1772622490,1,1772622490,2,1772622490,112,1772622490,224,1772622490,1,1772622490,2,1772622490 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_peak.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_peak.csv new file mode 100644 index 0000000000000..c916fae0f7464 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/interface_peak.csv @@ -0,0 +1,6 @@ +ifname,traftype,bytes_tx,bytes_tx_ts,bytes_rx,bytes_rx_ts,pkts_tx,pkts_tx_ts,pkts_rx,pkts_rx_ts,ohead_bytes_tx,ohead_bytes_tx_ts,ohead_bytes_rx,ohead_bytes_rx_ts,ohead_pkts_tx,ohead_pkts_tx_ts,ohead_pkts_rx,ohead_pkts_rx_ts,fwdrops_bytes_tx,fwdrops_bytes_tx_ts,fwdrops_bytes_rx,fwdrops_bytes_rx_ts,fwdrops_pkts_tx,fwdrops_pkts_tx_ts,fwdrops_pkts_rx,fwdrops_pkts_rx_ts,max_bw_tx,max_bw_tx_ts,max_bw_rx,max_bw_rx_ts +wan1,optimized traffic,816,1772622489,1664,1772622515,6,1772622511,10,1772622515,816,1772622489,1664,1772622515,6,1772622511,10,1772622515,0,0,0,0,0,0,0,0,100000,1772622489,100000,1772622489 +wan1,all traffic,816,1772622489,1664,1772622515,6,1772622511,10,1772622515,816,1772622489,1664,1772622515,6,1772622511,10,1772622515,0,0,0,0,0,0,0,0,100000,1772622489,100000,1772622489 +wan0,optimized traffic,576,1772622489,1152,1772622489,3,1772622489,6,1772622489,576,1772622489,1152,1772622489,3,1772622489,6,1772622489,0,0,0,0,0,0,0,0,100000,1772622489,100000,1772622489 +wan0,pass-through-unshaped,1332,1772622481,696,1772622481,6,1772622481,3,1772622481,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,100000,1772622489,100000,1772622489 +wan0,all traffic,1908,1772622489,1848,1772622489,9,1772622489,9,1772622489,576,1772622489,1152,1772622489,3,1772622489,6,1772622489,0,0,0,0,0,0,0,0,100000,1772622489,100000,1772622489 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/jitter.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/jitter.csv new file mode 100644 index 0000000000000..078b644a7bde8 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/jitter.csv @@ -0,0 +1,32 @@ +tunnel, jitter, peak_jitter, timestamp +pass-through, 0, 0, 0 +pass-through-unshaped, 0, 0, 0 +passThrough_1, 0, 0, 0 +passThrough_10, 0, 0, 0 +passThrough_11, 0, 0, 0 +passThrough_2, 0, 0, 0 +passThrough_3, 0, 0, 0 +passThrough_4, 0, 0, 0 +passThrough_5, 0, 0, 0 +passThrough_6, 0, 0, 0 +passThrough_7, 0, 0, 0 +passThrough_8, 0, 0, 0 +passThrough_9, 0, 0, 0 +tunnel_12, 0, 0, 0 +tunnel_13, 0, 0, 0 +bondedTunnel_14, 0, 0, 0 +bondedTunnel_15, 0, 0, 0 +bondedTunnel_16, 600, 6, 1772622481 +bondedTunnel_17, 600, 6, 1772622481 +tunnel_18, 0, 0, 0 +tunnel_19, 0, 0, 0 +tunnel_20, 0, 0, 0 +tunnel_21, 0, 0, 0 +bondedTunnel_22, 0, 0, 0 +bondedTunnel_23, 0, 0, 0 +bondedTunnel_24, 0, 0, 0 +bondedTunnel_25, 0, 0, 0 +bondedTunnel_26, 0, 0, 0 +bondedTunnel_27, 0, 0, 0 +bondedTunnel_28, 0, 0, 0 +bondedTunnel_29, 0, 0, 0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/mos.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/mos.csv new file mode 100644 index 0000000000000..16275d9cc4962 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/mos.csv @@ -0,0 +1,32 @@ +tunnel, mos_postfec, min_mos_postfec, min_mos_postfec_ts, mos_prefec, min_mos_prefec, min_mos_prefec_ts +pass-through, 0.00, 0.00, 0, 0.00, 0.00, 0 +pass-through-unshaped, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_1, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_10, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_11, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_2, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_3, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_4, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_5, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_6, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_7, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_8, 0.00, 0.00, 0, 0.00, 0.00, 0 +passThrough_9, 0.00, 0.00, 0, 0.00, 0.00, 0 +tunnel_12, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +tunnel_13, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_14, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_15, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_16, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_17, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +tunnel_18, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +tunnel_19, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +tunnel_20, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +tunnel_21, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_22, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_23, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_24, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_25, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_26, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_27, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_28, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 +bondedTunnel_29, 4.00, 4.00, 1772622480, 4.00, 4.00, 1772622480 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/probe_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/probe_v2.txt new file mode 100644 index 0000000000000..2e28318093ffd --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/probe_v2.txt @@ -0,0 +1,12 @@ +unknown,0,om_passThrough_6,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3074,2931,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,1,om_passThrough_6,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2071,1970,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,2,om_passThrough_6,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2790,2685,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,3,om_passThrough_7,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3074,2931,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,4,om_passThrough_7,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2071,1970,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,5,om_passThrough_7,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2790,2685,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,6,om_passThrough_3,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3074,2931,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,7,om_passThrough_3,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2071,1970,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,8,om_passThrough_3,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2790,2685,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,9,om_passThrough_9,sp-ipsla.silverpeak.cloud,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,3074,2931,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,10,om_passThrough_9,8.8.8.8,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2071,1970,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 +unknown,11,om_passThrough_9,8.8.4.4,type=ip-monitor;tunnel-name=;src-port-label=2,proxyAddress=;proxyPort=0;user-agent=;ka=1;http-timeout=0;up-thresh=3;down-thresh=30;sample-int=300;loss_up_thresh=0;loss_down_thresh=0;rtt_up_thresh=0;rtt_down_thresh=0;metric-combination=or,2790,2685,0,0,0,0,60,60,0,0,0,0,0,0,0,0,1772622540 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/shaper.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/shaper.csv new file mode 100644 index 0000000000000..b4a976dc7d9f6 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/shaper.csv @@ -0,0 +1,3 @@ +traffic_class, direction, total_bytes, shaped_bytes, shaped_packets, total_wait, total_wait_count, qos_drops, other_drops +2,1,12720,12720,60,0,60,0,0 +4,1,25440,25440,120,0,120,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_availability_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_availability_v2.txt new file mode 100644 index 0000000000000..d34a43973c934 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_availability_v2.txt @@ -0,0 +1,31 @@ +unknown,pass-through,pass-through,0,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,pass-through-unshaped,pass-through-unshaped,0,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_1,Passthrough_Data_lan0,0,1,2,00000000000000000000000000000000,0,0,0,0,1,Data,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_10,Passthrough_MPLS1_wan1,0,1,2,00000000000000000000000000000000,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_11,Passthrough_INET1_wan0,0,1,2,00000000000000000000000000000000,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_2,Passthrough_MPLS1_RealTime,1,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_3,Passthrough_INET1_RealTime,1,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_RealTime_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_4,Passthrough_MPLS1_CriticalApps,2,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_5,Passthrough_MPLS1_BulkApps,3,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_6,Passthrough_INET1_CriticalApps,2,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_CriticalApps_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_7,Passthrough_INET1_BulkApps,3,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_BulkApps_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_8,Passthrough_MPLS1_DefaultOverlay,4,1,2,00000000000000000000000000000000,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_9,Passthrough_INET1_DefaultOverlay,4,1,2,00000000000000000000000000000000,0,0,0,0,1,none,Overlay_DefaultOverlay_Primary,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,tunnel_12,to_NewYorkSP01_MPLS1-MPLS1,0,0,1,3d4d299f2d30ca90c3283cc6409b04eb,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,tunnel_13,to_NewYorkSP01_INET1-INET1,0,0,1,15875bda2e479ae57dbab202656c0a10,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_14,to_NewYorkSP01_DefaultOverlay,4,0,0,00dd04213acc688fb1f94f0f35dd1118,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_15,to_NewYorkSP01_BulkApps,3,0,0,2a0d71412033475810fb413acf55960e,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_16,to_NewYorkSP01_CriticalApps,2,0,0,64db1a819c167ef5786d5bebabb7e681,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_17,to_NewYorkSP01_RealTime,1,0,0,c67de1d41d24067d0404da27fbd05574,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,tunnel_18,to_SydneySP01_INET1-INET1,0,0,1,64a0d7865d0067ab26ccde85ff3d0bcb,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,tunnel_19,to_SydneySP01_MPLS1-MPLS1,0,0,1,b7988dcaf9c3ed4483f5f690303a7247,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,tunnel_20,to_SanFranSP02_INET1-INET1,0,0,1,9bf32ee020aa7d38abf87fb4a366e126,0,0,0,0,1,INET1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,tunnel_21,to_SanFranSP02_MPLS1-MPLS1,0,0,1,515fa7659491f9b5fbdeeb0c24e11330,0,0,0,0,1,MPLS1,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_22,to_SanFranSP02_RealTime,1,0,0,27c2202a76bcef413a9ef5b276ba9cc6,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_23,to_SydneySP01_DefaultOverlay,4,0,0,91b9acd13734384d6d8c84c441c4a428,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_24,to_SydneySP01_RealTime,1,0,0,e4dd914dd2e5dd3faa5b28dfbdfa25db,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_25,to_SydneySP01_CriticalApps,2,0,0,e23a62fc5fcdbbf9cb885fadc4b77580,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_26,to_SydneySP01_BulkApps,3,0,0,0b59da7eb69812150a8f5a911a5fbc6a,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_27,to_SanFranSP02_BulkApps,3,0,0,7ee606393b0eca78778c2d82edd0fcd3,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_28,to_SanFranSP02_DefaultOverlay,4,0,0,cbb8dd0025057d8744f813e19801078d,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,bondedTunnel_29,to_SanFranSP02_CriticalApps,2,0,0,0865b6310dd706cacd7bcd32ba3b96f7,0,0,0,0,1,none,unassigned,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_peak.csv b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_peak.csv new file mode 100644 index 0000000000000..57e6f5192def1 --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_peak.csv @@ -0,0 +1,34 @@ +tunname,bytes_wtx,bytes_wtx_ts,bytes_wrx,bytes_wrx_ts,bytes_ltx,bytes_ltx_ts,bytes_lrx,bytes_lrx_ts,pkts_wtx,pkts_wtx_ts,pkts_wrx,pkts_wrx_ts,pkts_ltx,pkts_ltx_ts,pkts_lrx,pkts_lrx_ts,comp_l2w,comp_l2w_ts,comp_w2l,comp_w2l_ts,comp_noohead_l2w,comp_noohead_l2w_ts,comp_noohead_w2l,comp_noohead_w2l_ts,latency_s,latency_s_ts,latency_min_s,latency_min_s_ts,flow_ext_tcp,flow_ext_tcp_ts,flow_ext_tcpacc,flow_ext_tcpacc_ts,flow_ext_non,flow_ext_non_ts,flow_add,flow_add_ts,flow_rem,flow_rem_ts,loss_prefec_wrx_pkts,loss_prefec_wrx_pkts_ts,loss_postfec_wrx_pkts,loss_postfec_wrx_pkts_ts,loss_prefec_wrx_pct,loss_prefec_wrx_pct_ts,loss_postfec_wrx_pct,loss_postfec_wrx_pct_ts,ooo_prepoc_wrx_pkts,ooo_prepoc_wrx_pkts_ts,ooo_postpoc_wrx_pkts,ooo_postpoc_wrx_pkts_ts,ooo_prepoc_wrx_pct,ooo_prepoc_wrx_pct_ts,ooo_postpoc_wrx_pct,ooo_postpoc_wrx_pct_ts,ohead_wrx_pkts,ohead_wrx_pkts_ts,ohead_wtx_pkts,ohead_wtx_pkts_ts,ohead_wrx_bytes,ohead_wrx_bytes_ts,ohead_wtx_bytes,ohead_wtx_bytes_ts,ohead_wrx_hdr_bytes,ohead_wrx_hdr_bytes_ts,ohead_wtx_hdr_bytes,ohead_wtx_hdr_bytes_ts,bw_util_pct,bw_util_pct_ts +pass-through,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +pass-through-unshaped,1272,1772622520,1160,1772622527,1160,1772622527,1272,1772622520,6,1772622520,5,1772622527,5,1772622527,6,1772622520,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,1772622481,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,1772622481,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +passThrough_9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,140,1772622486,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_13,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,140,1772622486,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,137,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,138,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,144,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_17,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,144,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_18,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,55,1772622537,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_19,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,55,1772622537,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_20,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,1772622495,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +tunnel_21,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,1772622495,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_23,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,52,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_24,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,52,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,52,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_26,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,52,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_28,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +bondedTunnel_29,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1772622481,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +all traffic,1272,1772622520,1160,1772622527,1160,1772622527,1272,1772622520,6,1772622520,5,1772622527,5,1772622527,6,1772622520,0,0,0,0,0,0,0,0,144,1772622481,0,0,0,0,0,0,3,1772622481,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +optimized traffic,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,144,1772622481,0,0,0,0,0,0,3,1772622481,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 diff --git a/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_v2.txt b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_v2.txt new file mode 100644 index 0000000000000..e4c566be35bfc --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/fixtures/st2-100000060/tunnel_v2.txt @@ -0,0 +1,33 @@ +unknown,pass-through,pass-through,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,pass-through-unshaped,pass-through-unshaped,0,1,00000000000000000000000000000000,38796,42456,42456,38796,183,183,183,183,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5124,0,4944670917648716472,4940730267974767295,4940730267974767295,4944670917648716472,4665729215728456376,4656722016473715391,4656722016473715391,4665729215728456376,0,0,0,4629700418709492369,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_1,Passthrough_Data_lan0,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_10,Passthrough_MPLS1_wan1,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_11,Passthrough_INET1_wan0,0,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4629700418709492369,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_2,Passthrough_MPLS1_RealTime,1,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_3,Passthrough_INET1_RealTime,1,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_4,Passthrough_MPLS1_CriticalApps,2,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_5,Passthrough_MPLS1_BulkApps,3,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_6,Passthrough_INET1_CriticalApps,2,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_7,Passthrough_INET1_BulkApps,3,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_8,Passthrough_MPLS1_DefaultOverlay,4,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,passThrough_9,Passthrough_INET1_DefaultOverlay,4,1,00000000000000000000000000000000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1772622540 +unknown,tunnel_12,to_NewYorkSP01_MPLS1-MPLS1,0,0,3d4d299f2d30ca90c3283cc6409b04eb,0,0,0,0,0,0,0,0,139,138,0,0,0,0,0,0,0,0,0,0,0,0,0,30,26,6192,5968,0,0,0,0,0,0,0,0,0,0,0,4831236502034322070,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,tunnel_13,to_NewYorkSP01_INET1-INET1,0,0,15875bda2e479ae57dbab202656c0a10,0,0,0,0,0,0,0,0,139,138,0,0,0,0,0,0,0,0,0,0,0,0,0,19,15,4768,4320,0,0,0,0,0,0,0,0,0,0,0,4831236502034322070,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_14,to_NewYorkSP01_DefaultOverlay,4,0,00dd04213acc688fb1f94f0f35dd1118,0,0,0,0,0,0,0,0,137,137,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,224,112,0,0,0,0,0,0,0,0,0,0,0,4830392077104190097,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_15,to_NewYorkSP01_BulkApps,3,0,2a0d71412033475810fb413acf55960e,0,0,0,0,0,0,0,0,138,138,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,224,112,0,0,0,0,0,0,0,0,0,0,0,4830673552080900753,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_16,to_NewYorkSP01_CriticalApps,2,0,64db1a819c167ef5786d5bebabb7e681,0,0,0,0,0,0,0,0,144,144,0,0,0,0,0,0,0,0,0,0,0,0,0,4,2,448,224,0,0,0,0,0,0,0,0,0,0,0,4832362401941164689,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,600,4665729215728456337,1772622540 +unknown,bondedTunnel_17,to_NewYorkSP01_RealTime,1,0,c67de1d41d24067d0404da27fbd05574,0,0,0,0,0,0,0,0,144,144,0,0,0,0,0,0,0,0,0,0,0,0,0,4,2,448,224,0,0,0,0,0,0,0,0,0,0,0,4832362401941164689,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,600,4665729215728456337,1772622540 +unknown,tunnel_18,to_SydneySP01_INET1-INET1,0,0,64a0d7865d0067ab26ccde85ff3d0bcb,0,0,0,0,0,0,0,0,53,53,0,0,0,0,0,0,0,0,0,0,0,0,0,14,12,4416,4224,0,0,0,0,0,0,0,0,0,0,0,4781696906133246665,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,tunnel_19,to_SydneySP01_MPLS1-MPLS1,0,0,b7988dcaf9c3ed4483f5f690303a7247,0,0,0,0,0,0,0,0,53,53,0,0,0,0,0,0,0,0,0,0,0,0,0,17,19,5008,5072,0,0,0,0,0,0,0,0,0,0,0,4781696906133246665,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,tunnel_20,to_SanFranSP02_INET1-INET1,0,0,9bf32ee020aa7d38abf87fb4a366e126,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,14,12,4416,4224,0,0,0,0,0,0,0,0,0,0,0,4629700418709492383,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,tunnel_21,to_SanFranSP02_MPLS1-MPLS1,0,0,515fa7659491f9b5fbdeeb0c24e11330,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,12,12,4224,4224,0,0,0,0,0,0,0,0,0,0,0,4629700418709492383,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_22,to_SanFranSP02_RealTime,1,0,27c2202a76bcef413a9ef5b276ba9cc6,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,96,0,0,0,0,0,0,0,0,0,0,0,0,4611686020200010385,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_23,to_SydneySP01_DefaultOverlay,4,0,91b9acd13734384d6d8c84c441c4a428,0,0,0,0,0,0,0,0,52,52,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4778319206412718737,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_24,to_SydneySP01_RealTime,1,0,e4dd914dd2e5dd3faa5b28dfbdfa25db,0,0,0,0,0,0,0,0,52,52,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,96,0,0,0,0,0,0,0,0,0,0,0,0,4778319206412718737,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_25,to_SydneySP01_CriticalApps,2,0,e23a62fc5fcdbbf9cb885fadc4b77580,0,0,0,0,0,0,0,0,52,52,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,96,0,0,0,0,0,0,0,0,0,0,0,0,4778319206412718737,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_26,to_SydneySP01_BulkApps,3,0,0b59da7eb69812150a8f5a911a5fbc6a,0,0,0,0,0,0,0,0,52,52,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,112,224,0,0,0,0,0,0,0,0,0,0,0,4778319206412718737,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_27,to_SanFranSP02_BulkApps,3,0,7ee606393b0eca78778c2d82edd0fcd3,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4611686020200010385,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_28,to_SanFranSP02_DefaultOverlay,4,0,cbb8dd0025057d8744f813e19801078d,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4575657223181046417,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,bondedTunnel_29,to_SanFranSP02_CriticalApps,2,0,0865b6310dd706cacd7bcd32ba3b96f7,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,96,0,0,0,0,0,0,0,0,0,0,0,0,4611686020200010385,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,400,400,4884153797655925392,4884153797655925392,0,0,1772622540 +unknown,all traffic,all traffic,0,1,unknown,38796,42456,42456,38796,183,183,183,183,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,106,96,29024,28032,0,5124,0,4944670917648716472,4940730267974767295,4940730267974767295,4944670917648716472,4665729215728456376,4656722016473715391,4656722016473715391,4665729215728456376,4832362401941164689,0,0,4629700418709492369,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4665729215728456337,1772622540 +unknown,optimized traffic,optimized traffic,0,1,unknown,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,106,96,29024,28032,0,0,0,0,0,0,0,0,0,0,0,4832362401941164689,0,0,4629700418709492369,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4665729215728456337,1772622540 diff --git a/hpe_aruba_edgeconnect/tests/test_e2e.py b/hpe_aruba_edgeconnect/tests/test_e2e.py new file mode 100644 index 0000000000000..b59f4d829206a --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/test_e2e.py @@ -0,0 +1,30 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import pytest + +from datadog_checks.dev.utils import get_metadata_metrics + +from .common import E2E_EXPECTED_METRIC_COUNTS, E2E_EXPECTED_VALUES, EXCLUDED_APPLIANCE_IP, NS + + +@pytest.mark.e2e +def test_e2e(dd_agent_check): + aggregator = dd_agent_check() + + aggregator.assert_metrics_using_metadata(get_metadata_metrics()) + + for metric_name, expected_count in E2E_EXPECTED_METRIC_COUNTS.items(): + aggregator.assert_metric(f'{NS}.{metric_name}', count=expected_count) + + for metric_name, expected_value, tag_subset in E2E_EXPECTED_VALUES: + full_name = f'{NS}.{metric_name}' + aggregator.assert_metric(full_name, value=expected_value) + if tag_subset: + aggregator.assert_metric_has_tags(full_name, tag_subset) + + # The excluded appliance must not produce a device.reachability metric. + for metric in aggregator.metrics(f'{NS}.device.reachability'): + assert f'device_ip:{EXCLUDED_APPLIANCE_IP}' not in metric.tags + + aggregator.assert_all_metrics_covered() diff --git a/hpe_aruba_edgeconnect/tests/test_unit.py b/hpe_aruba_edgeconnect/tests/test_unit.py new file mode 100644 index 0000000000000..5f00b0c8eceea --- /dev/null +++ b/hpe_aruba_edgeconnect/tests/test_unit.py @@ -0,0 +1,697 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import json +from unittest.mock import MagicMock + +import pytest + +from datadog_checks.dev.utils import get_metadata_metrics +from datadog_checks.hpe_aruba_edgeconnect import HpeArubaEdgeconnectCheck +from datadog_checks.hpe_aruba_edgeconnect.client import ApplianceClient, OrchestratorClient +from datadog_checks.hpe_aruba_edgeconnect.minute_stats import MinuteStats +from datadog_checks.hpe_aruba_edgeconnect.ndm_models import PAYLOAD_METADATA_BATCH_SIZE + +from .common import ( + ALARM_PAYLOAD, + APPLIANCE_PAYLOAD, + BASE_DEVICE_TAGS, + CHECK_MODULE, + EXPECTED_METRIC_COUNTS, + EXPECTED_VALUES, + FIXTURE_DIR, + NEWEST_TS, + NS, + TGZ_BYTES, + TGZ_DATA, + _mock_appliance_client, + _pack_dir_to_tgz_bytes, + _setup_mocks, +) + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + 'appliance_ips', + [ + pytest.param({'include': ['bad-pattern']}, id='include'), + pytest.param({'exclude': ['bad-pattern']}, id='exclude'), + ], +) +def test_config_rejects_invalid_appliance_ip_patterns(instance, appliance_ips): + inst = instance('localhost:8443', appliance_ips=appliance_ips) + c = HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + with pytest.raises(Exception, match='Invalid appliance_ips pattern'): + c.load_configuration_models() + + +# --------------------------------------------------------------------------- +# Appliance models +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + 'ips, filter_config, expected_ips', + [ + pytest.param( + ['10.0.0.1', '10.0.0.2', '10.0.0.3'], + {'include': ['10.0.0.1', '10.0.0.3']}, + ['10.0.0.1', '10.0.0.3'], + id='include', + ), + pytest.param( + ['10.0.0.1', '10.0.0.2', '10.0.0.3'], + {'exclude': ['10.0.0.2']}, + ['10.0.0.1', '10.0.0.3'], + id='exclude', + ), + pytest.param( + ['10.0.0.1', '10.0.0.2', '10.0.0.3'], + {'include': ['10.0.0.1', '10.0.0.2'], 'exclude': ['10.0.0.2']}, + ['10.0.0.1'], + id='include_and_exclude', + ), + pytest.param( + ['10.0.0.1', '10.0.0.2'], + None, + ['10.0.0.1', '10.0.0.2'], + id='none', + ), + pytest.param( + ['10.0.0.5', '10.0.1.5', '10.0.0.200'], + {'include': ['10.0.0.0/24']}, + ['10.0.0.5', '10.0.0.200'], + id='cidr', + ), + ], +) +def test_appliances_filter(dd_run_check, aggregator, mocker, instance, ips, filter_config, expected_ips): + inst = instance('localhost:8443', appliance_ips=filter_config, max_backfill_minutes=10) + check = HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + payload = [{**APPLIANCE_PAYLOAD[0], 'ip': ip, 'hostName': f'host-{ip}'} for ip in ips] + _setup_mocks(mocker, check, payload, appliance_client=_mock_appliance_client(TGZ_DATA)) + + dd_run_check(check) + + monitored_ips = sorted( + tag.split(':', 1)[1] + for metric in aggregator.metrics(f'{NS}.device.reachability') + for tag in metric.tags + if tag.startswith('device_ip:') + ) + assert monitored_ips == sorted(expected_ips) + + +@pytest.mark.parametrize( + 'value, expected', + [ + pytest.param('1000Mb/s (auto)', 1_000_000_000, id='1000mbps_auto'), + pytest.param('25000Mb/s (auto)', 25_000_000_000, id='25000mbps_auto'), + pytest.param('100Mb/s', 100_000_000, id='100mbps'), + pytest.param('10Gb/s', 10_000_000_000, id='10gbps'), + pytest.param('100Kb/s', 100_000, id='100kbps'), + pytest.param(1000000, 1000000, id='int_passthrough'), + pytest.param(1000000.0, 1000000.0, id='float_passthrough'), + pytest.param(None, None, id='none'), + pytest.param('unknown', None, id='unparseable'), + ], +) +def test_parse_speed(dd_run_check, aggregator, mocker, check, value, expected): + client = _mock_appliance_client(TGZ_DATA) + client.get_network_interfaces.return_value = {'ifInfo': [{'ifname': 'wan0', 'admin': 1, 'oper': 1, 'speed': value}]} + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD, appliance_client=client) + + dd_run_check(check) + + if expected is None: + aggregator.assert_metric(f'{NS}.interface.speed', count=0) + else: + aggregator.assert_metric(f'{NS}.interface.speed', value=expected, count=1) + + +# --------------------------------------------------------------------------- +# Auth, credentials, and login +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + 'ip, appliance_credentials, expected_username, expected_password', + [ + pytest.param( + '192.168.1.5', + [{'cidr': '192.168.1.0/24', 'username': 'cidr_user', 'password': 'cidr_pass'}], + 'cidr_user', + 'cidr_pass', + id='cidr_match', + ), + pytest.param( + '10.0.0.1', + [{'cidr': '192.168.1.0/24', 'username': 'cidr_user', 'password': 'cidr_pass'}], + 'admin', + 'default_pass', + id='fallback_to_shared', + ), + pytest.param( + '10.0.0.1', + None, + 'admin', + 'default_pass', + id='no_overrides', + ), + pytest.param( + '10.0.0.1', + [ + {'cidr': 'not-a-cidr', 'username': 'bad', 'password': 'bad'}, + {'cidr': '10.0.0.0/24', 'username': 'good', 'password': 'good'}, + ], + 'good', + 'good', + id='invalid_cidr_skipped', + ), + pytest.param( + '192.168.1.5', + [{'cidr': '192.168.1.0/24', 'username': 'cidr_user', 'password': ''}], + 'cidr_user', + '', + id='empty_password_is_valid', + ), + pytest.param( + '192.168.1.5', + [ + {'cidr': '192.168.1.0/24', 'username': 'first_user', 'password': 'first_pass'}, + {'cidr': '192.168.0.0/16', 'username': 'second_user', 'password': 'second_pass'}, + ], + 'first_user', + 'first_pass', + id='first_match_wins', + ), + ], +) +def test_resolve_credentials( + dd_run_check, mocker, instance, ip, appliance_credentials, expected_username, expected_password +): + inst = instance( + 'localhost:8443', + orchestrator_username='admin', + orchestrator_password='default_pass', + appliance_credentials=appliance_credentials, + ) + check = HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + payload = [{**APPLIANCE_PAYLOAD[0], 'ip': ip}] + _setup_mocks(mocker, check, payload) + create_client = mocker.patch.object( + check, '_create_appliance_client', return_value=_mock_appliance_client(TGZ_DATA, app_ip=ip) + ) + + dd_run_check(check) + + create_client.assert_called_once_with(ip, expected_username, expected_password) + + +@pytest.mark.parametrize( + 'cached_value, latest_timestamp, expected', + [ + pytest.param(None, 1000, [1000], id='first_run'), + pytest.param('1000', 1000, [], id='up_to_date'), + pytest.param('100', 220, [220, 160], id='catchup_newest_first'), + pytest.param( + '100', + 100 + 12 * 60, + [(100 + 12 * 60) - i * 60 for i in range(10)], + id='catchup_capped_at_max_backfill', + ), + ], +) +def test_timestamps_to_fetch(dd_run_check, mocker, check, cached_value, latest_timestamp, expected): + client = _mock_appliance_client(TGZ_BYTES[0], newest_timestamp=latest_timestamp) + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD, appliance_client=client, cached_timestamp=cached_value) + warning = mocker.patch.object(check.log, 'warning') + + dd_run_check(check) + + fetched = {call.args[0] for call in client.get_minute_stats.call_args_list} + assert fetched == {f'st2-{ts}.tgz' for ts in expected} + + capped = len(expected) == check.config.max_backfill_minutes and cached_value is not None + backfill_warned = any('capping backfill' in str(call.args[0]) for call in warning.call_args_list) + assert backfill_warned is capped + + +def test_qos_metrics_omit_overlay_tag_without_traffic_class_mapping(dd_run_check, aggregator, mocker, check): + # No overlay/traffic-class mapping is returned by the orchestrator, so shaper + # metrics must be emitted without an overlay_name tag. + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD, appliance_client=_mock_appliance_client(TGZ_DATA)) + + dd_run_check(check) + + qos_metrics = aggregator.metrics(f'{NS}.qos.class.drops') + assert qos_metrics, 'expected qos.class.drops metrics to be emitted' + for metric in qos_metrics: + assert not any(tag.startswith('overlay_name:') for tag in metric.tags) + + +def test_login_appliance_csrf_token(): + http = MagicMock() + http.session.cookies = {'edgeosCsrfToken': 'mytoken'} + http.session.headers = {} + http.post.return_value = MagicMock(raise_for_status=MagicMock()) + logger = MagicMock() + + client = ApplianceClient(http, '10.0.0.1', logger) + client.login('admin', 'pass') + + assert http.session.headers.get('X-XSRF-TOKEN') == 'mytoken' + + +def test_login_appliance_session_id_fallback(): + http = MagicMock() + http.session.cookies = {'vxoaSessionID': 'sess123'} + http.session.headers = {} + http.post.return_value = MagicMock(raise_for_status=MagicMock()) + logger = MagicMock() + + client = ApplianceClient(http, '10.0.0.1', logger) + client.login('admin', 'pass') + + assert http.session.headers.get('vxoaSessionID') == 'sess123' + + +def test_login_orchestrator_csrf_token(): + http = MagicMock() + http.session.cookies = {'orchCsrfToken': 'orchtoken'} + http.session.headers = {} + http.post.return_value = MagicMock(raise_for_status=MagicMock()) + + client = OrchestratorClient(http, '10.0.0.1') + client.login('admin', 'pass') + + assert http.session.headers.get('X-XSRF-TOKEN') == 'orchtoken' + + +@pytest.mark.parametrize( + 'client_factory, login_url', + [ + pytest.param( + lambda http: ApplianceClient(http, '10.0.0.1', MagicMock()), + 'https://10.0.0.1/rest/json/login', + id='appliance', + ), + pytest.param( + lambda http: OrchestratorClient(http, '10.0.0.1'), + 'https://10.0.0.1/gms/rest/authentication/login', + id='orchestrator', + ), + ], +) +def test_request_retries_once_on_401(client_factory, login_url): + http = MagicMock() + http.session.cookies = {} + http.session.headers = {} + http.post.return_value = MagicMock(raise_for_status=MagicMock()) + http.get.side_effect = [ + MagicMock(status_code=401, raise_for_status=MagicMock()), + MagicMock(status_code=200, raise_for_status=MagicMock()), + ] + + client = client_factory(http) + client.login('admin', 'pass') + resp = client._request('get', '/some/path') + + assert resp.status_code == 200 + assert http.get.call_count == 2 + assert http.post.call_count == 2 + assert http.post.call_args_list[0].args[0] == login_url + assert http.post.call_args_list[1].args[0] == login_url + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + + +def test_empty_minute_stats_files_emit_no_metrics(dd_run_check, aggregator, mocker, check): + empty_archive = _pack_dir_to_tgz_bytes( + FIXTURE_DIR / f'st2-{NEWEST_TS}', + dict.fromkeys(MinuteStats.FILES_NEEDED, ''), + ) + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD, appliance_client=_mock_appliance_client(empty_archive)) + # _safe_parse swallows parser exceptions and logs them, so guard against a + # parser that chokes on empty input rather than returning an empty list. + log_exception = mocker.patch.object(check.log, 'exception') + + dd_run_check(check) + + log_exception.assert_not_called() + for metric_name in ( + 'interface.bandwidth.tx.count', + 'tunnel.latency', + 'tunnel.internet_breakout.bandwidth.tx.count', + 'qos.class.drops', + 'circuit.sla.latency', + 'nexthop.status', + ): + aggregator.assert_metric(f'{NS}.{metric_name}', count=0) + # The run still completed and emitted endpoint-derived metrics. + aggregator.assert_metric(f'{NS}.device.reachability', count=1) + + +# --------------------------------------------------------------------------- +# Check integration +# --------------------------------------------------------------------------- + + +def test_all_metrics_covered(all_metrics_aggregator): + all_metrics_aggregator.assert_metrics_using_metadata( + get_metadata_metrics(), + check_submission_type=True, + check_metric_type=True, + check_symmetric_inclusion=True, + ) + + +def test_metric_counts(all_metrics_aggregator): + for metric_name, expected_count in sorted(EXPECTED_METRIC_COUNTS.items()): + all_metrics_aggregator.assert_metric(f'{NS}.{metric_name}', count=expected_count) + + +def test_metric_values(all_metrics_aggregator): + for metric_name, expected_value, tag_subset in EXPECTED_VALUES: + full_name = f'{NS}.{metric_name}' + all_metrics_aggregator.assert_metric(full_name, value=expected_value) + if tag_subset: + all_metrics_aggregator.assert_metric_has_tags(full_name, tag_subset) + + +def test_metrics_carry_base_device_tags(all_metrics_aggregator): + for metric_name in all_metrics_aggregator.metric_names: + if not metric_name.startswith(f'{NS}.') or metric_name == f'{NS}.orchestrator.reachability': + continue + for stub in all_metrics_aggregator.metrics(metric_name): + missing = [t for t in BASE_DEVICE_TAGS if t not in stub.tags] + assert not missing, f'{metric_name} is missing base device tags {missing}; got {stub.tags}' + + +def test_collection_step_failure_does_not_block_others(dd_run_check, aggregator, mocker, check): + tgz_bytes = TGZ_BYTES[0] + client = _mock_appliance_client(tgz_bytes, cpu=42) + client.get_network_interfaces.side_effect = Exception('network error') + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD, appliance_client=client) + + dd_run_check(check) + + aggregator.assert_metric(f'{NS}.device.cpu.usage', count=4) + aggregator.assert_metric(f'{NS}.device.cpu.usage', value=42 * 0.6, tags=BASE_DEVICE_TAGS + ['cpu_state:user']) + aggregator.assert_metric(f'{NS}.device.hardware.ok', count=1) + + +def _events_check(instance): + inst = instance('localhost:8443', appliance_ips=['10.0.0.1'], max_backfill_minutes=10, collect_events=True) + return HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + +def test_alarm_events_submitted(dd_run_check, aggregator, mocker, instance): + check = _events_check(instance) + client = _mock_appliance_client(TGZ_BYTES[0], alarms=ALARM_PAYLOAD) + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD[:1], appliance_client=client) + + dd_run_check(check) + + aggregator.assert_event( + 'All NTP servers are unreachable', + count=1, + exact_match=False, + alert_type='warning', + msg_title='[HPE Aruba EdgeConnect] Warning: All NTP servers are unreachable', + event_type='SW', + tags=BASE_DEVICE_TAGS + + [ + 'alarm_severity:warning', + 'alarm_source:System', + 'alarm_name:ntpd_server_unreachable', + ], + ) + events = aggregator.events + assert len(events) == 1 + event = events[0] + assert event['aggregation_key'] == '10.0.0.1:3777' + assert event['timestamp'] == 1779178081 + assert 'Recommendation:' in event['msg_text'] + check.write_persistent_cache.assert_any_call('last_alarm_ts:10.0.0.1', '1779178081000') + + +def test_alarm_events_not_collected_when_disabled(dd_run_check, aggregator, mocker, check): + client = _mock_appliance_client(TGZ_BYTES[0], alarms=ALARM_PAYLOAD) + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD[:1], appliance_client=client) + + dd_run_check(check) + + assert aggregator.events == [] + + +def test_alarm_events_deduped_across_runs(dd_run_check, aggregator, mocker, instance): + check = _events_check(instance) + client = _mock_appliance_client(TGZ_BYTES[0], alarms=ALARM_PAYLOAD) + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD[:1], appliance_client=client) + + cache: dict[str, str] = {} + check.read_persistent_cache.side_effect = lambda key: cache.get(key) + check.write_persistent_cache.side_effect = lambda key, value: cache.__setitem__(key, value) + + dd_run_check(check) + assert len([e for e in aggregator.events if 'NTP' in e['msg_text']]) == 1 + assert cache['last_alarm_ts:10.0.0.1'] == '1779178081000' + + aggregator.reset() + dd_run_check(check) + assert [e for e in aggregator.events if 'NTP' in e['msg_text']] == [] + + +def test_alarm_events_emitted_when_newer_than_watermark(dd_run_check, aggregator, mocker, instance): + check = _events_check(instance) + client = _mock_appliance_client(TGZ_BYTES[0], alarms=ALARM_PAYLOAD) + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD[:1], appliance_client=client) + # Watermark sits just before the alarm's raised time, so the alarm is still new. + check.read_persistent_cache.return_value = '1779178080999' + + dd_run_check(check) + + assert len([e for e in aggregator.events if 'NTP' in e['msg_text']]) == 1 + check.write_persistent_cache.assert_any_call('last_alarm_ts:10.0.0.1', '1779178081000') + + +def test_orchestrator_login_failure_emits_no_metrics(dd_run_check, aggregator, mocker, check): + orch = _setup_mocks(mocker, check, APPLIANCE_PAYLOAD) + mocker.patch.object(orch, 'login', side_effect=Exception('bad credentials')) + + with pytest.raises(Exception, match='bad credentials'): + dd_run_check(check, extract_message=True) + + emitted = [m for m in aggregator.metric_names if m.startswith(f'{NS}.')] + assert emitted == [f'{NS}.orchestrator.reachability'] + aggregator.assert_metric(f'{NS}.orchestrator.reachability', value=0, count=1) + assert aggregator.get_event_platform_events('network-devices-metadata') == [] + orch.get_appliances.assert_not_called() + assert check._orch_client is None + + +def test_up_to_date_appliance_skips_minute_stats_recording(dd_run_check, aggregator, mocker, check): + tgz_bytes = TGZ_BYTES[0] + client = _mock_appliance_client(tgz_bytes) + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD, appliance_client=client, cached_timestamp=str(NEWEST_TS)) + + dd_run_check(check) + + check.write_persistent_cache.assert_not_called() + + +def test_ndm_metadata_submitted(dd_run_check, aggregator, mocker, instance): + inst = instance( + 'localhost:8443', + appliance_ips=['10.0.0.1'], + max_backfill_minutes=10, + send_ndm_metadata=True, + ) + check = HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + tgz_bytes = TGZ_BYTES[0] + + client = _mock_appliance_client(tgz_bytes) + client.get_network_interfaces.return_value = { + 'ifInfo': [ + {'ifname': 'wan0', 'mac': 'aa:bb:cc:dd:ee:ff', 'admin': True, 'oper': True, 'speed': '1000Mb/s (auto)'}, + {'ifname': 'wan0:v100', 'admin': False, 'oper': None}, + {'ifname': 'lan0', 'admin': None, 'oper': False}, + ] + } + + client.get_interface_labels.return_value = { + 'wan': {'1': 'INET1', '2': 'MPLS1'}, + 'lan': {'3': 'Data', '4': 'Voice'}, + } + + _setup_mocks( + mocker, check, APPLIANCE_PAYLOAD, appliance_client=client, overlay_config=[{'id': 0, 'name': 'business'}] + ) + + dd_run_check(check) + + payloads = [ + e if isinstance(e, dict) else json.loads(e) + for e in aggregator.get_event_platform_events('network-devices-metadata') + ] + assert payloads, 'expected at least one NDM metadata payload to be submitted' + + devices = [d for p in payloads for d in p.get('devices', [])] + interfaces = [i for p in payloads for i in p.get('interfaces', [])] + tunnels = [t for p in payloads for t in p.get('tunnels', [])] + + # Devices + assert len(devices) == 1 + device = devices[0] + assert device['id'] == 'default:10.0.0.1' + assert device['ip_address'] == '10.0.0.1' + assert device['name'] == 'SydneySP01' + assert device['vendor'] == 'aruba' + assert device['os_name'] == 'ECOS' + assert device['serial_number'] == 'SN001' + assert device['product_name'] == 'EC-V' + assert device['device_type'] == 'router' + assert device['version'] == '9.3.1' + assert device['location'] == 'SYD' + assert device['site_id'] == 'SYD' + assert device['site_name'] == 'SYD' + assert device['status'] == 1 + assert 'device_namespace:default' in device['id_tags'] + assert 'device_ip:10.0.0.1' in device['id_tags'] + assert 'device_hostname:SydneySP01' in device['tags'] + assert 'device_id:default:10.0.0.1' in device['tags'] + + # Interfaces: VLAN parsing + admin/oper status conversion + assert len(interfaces) == 3 + by_name = {i['raw_id']: i for i in interfaces} + + wan0 = by_name['wan0'] + assert wan0['device_id'] == 'default:10.0.0.1' + assert wan0['id_tags'] == ['interface:wan0'] + assert wan0['mac_address'] == 'aa:bb:cc:dd:ee:ff' + assert wan0['admin_status'] == 1 + assert wan0['oper_status'] == 1 + assert 'vlan' not in wan0 + + vlan_iface = by_name['wan0:v100'] + assert vlan_iface['vlan'] == 100 + assert vlan_iface['admin_status'] == 2 + assert vlan_iface['oper_status'] == 4 + + lan0 = by_name['lan0'] + assert 'admin_status' not in lan0 + assert lan0['oper_status'] == 2 + + # Tunnels: matching alias with peer in lookup + by_alias = {t['path_name']: t for t in tunnels} + + with_peer = by_alias['to_SydneySP01_INET1-INET1'] + assert with_peer['src_device_id'] == 'default:10.0.0.1' + assert with_peer['dst_device_id'] == 'default:10.0.0.1' + assert with_peer['src_site_id'] == 'SYD' + assert with_peer['dst_site_id'] == 'SYD' + assert with_peer['tunnel_color'] == 'INET1-INET1' + + # Tunnels: matching alias with peer resolved from orchestrator appliance list + ny_peer = by_alias['to_NewYorkSP01_MPLS1-MPLS1'] + assert ny_peer['dst_device_id'] == 'default:10.0.0.2' + assert ny_peer['dst_site_id'] == 'NYC' + assert ny_peer['tunnel_color'] == 'MPLS1-MPLS1' + assert ny_peer['overlay_name'] == 'business' + + # Tunnels: passthrough alias whose middle token is a WAN label resolves to a tunnel_color + passthrough_wan = by_alias['Passthrough_INET1_wan0'] + assert passthrough_wan['dst_device_id'] == '' + assert passthrough_wan['dst_site_id'] == '' + assert passthrough_wan['tunnel_color'] == 'INET1' + + # Tunnels: passthrough alias whose middle token is a LAN label is not treated as a tunnel_color + non_matching = by_alias['Passthrough_Data_lan0'] + assert non_matching['dst_device_id'] == '' + assert non_matching['dst_site_id'] == '' + assert non_matching['tunnel_color'] == '' + + # Payload batching and namespace propagation + for payload in payloads: + size = len(payload.get('devices', [])) + len(payload.get('interfaces', [])) + len(payload.get('tunnels', [])) + assert 0 < size <= PAYLOAD_METADATA_BATCH_SIZE + assert payload['namespace'] == 'default' + assert payload['collect_timestamp'] is not None + + +def test_tunnel_metadata_uses_source_minute_stats_timestamp_during_backfill(dd_run_check, aggregator, mocker, instance): + inst = instance( + 'localhost:8443', + appliance_ips=['10.0.0.1'], + max_backfill_minutes=10, + send_ndm_metadata=True, + ) + check = HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + tunnel_stats_ts = NEWEST_TS - 60 + newest_without_tunnels = _pack_dir_to_tgz_bytes( + FIXTURE_DIR / f'st2-{NEWEST_TS}', + {'tunnel_v2.txt': ''}, + ) + client = _mock_appliance_client( + { + f'st2-{NEWEST_TS}.tgz': newest_without_tunnels, + f'st2-{tunnel_stats_ts}.tgz': TGZ_DATA[f'st2-{tunnel_stats_ts}.tgz'], + } + ) + _setup_mocks( + mocker, + check, + APPLIANCE_PAYLOAD, + appliance_client=client, + cached_timestamp=str(tunnel_stats_ts - 60), + ) + + dd_run_check(check) + + payloads = [ + e if isinstance(e, dict) else json.loads(e) + for e in aggregator.get_event_platform_events('network-devices-metadata') + ] + tunnel_payloads = [p for p in payloads if p.get('tunnels')] + assert tunnel_payloads, 'expected tunnel metadata to be submitted' + assert {p['collect_timestamp'] for p in tunnel_payloads} == {tunnel_stats_ts} + + +def test_stale_appliance_clients_cleaned_up(dd_run_check, mocker, instance): + inst = instance('localhost:8443', appliance_ips=['10.0.0.0/24'], max_backfill_minutes=10) + check = HpeArubaEdgeconnectCheck('hpe_aruba_edgeconnect', {}, [inst]) + + def make_client(http, app_ip, log): + return _mock_appliance_client(TGZ_DATA, app_ip=app_ip) + + mocker.patch(f'{CHECK_MODULE}.ApplianceClient', side_effect=make_client) + + payload_with_extra = APPLIANCE_PAYLOAD[:1] + [ + {**APPLIANCE_PAYLOAD[0], 'ip': '10.0.0.99', 'hostName': 'StaleAppliance'}, + ] + _setup_mocks(mocker, check, payload_with_extra) + dd_run_check(check) + + assert set(check._appliance_clients) == {'10.0.0.1', '10.0.0.99'} + stale_client = check._appliance_clients['10.0.0.99'] + + _setup_mocks(mocker, check, APPLIANCE_PAYLOAD[:1]) + dd_run_check(check) + + assert set(check._appliance_clients) == {'10.0.0.1'} + assert stale_client not in check._appliance_clients.values()