ipmg 3.0.0__tar.gz → 3.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ipmg-3.0.0/src/ipmg.egg-info → ipmg-3.1.0}/PKG-INFO +52 -1
- {ipmg-3.0.0 → ipmg-3.1.0}/README.md +51 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/pyproject.toml +1 -1
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/__init__.py +1 -1
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/commands.py +1 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/parser.py +19 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/database.py +17 -1
- ipmg-3.1.0/src/ipmg/reporting/metrics.py +125 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/app.py +27 -2
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/server.py +7 -1
- {ipmg-3.0.0 → ipmg-3.1.0/src/ipmg.egg-info}/PKG-INFO +52 -1
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/SOURCES.txt +2 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_commands.py +9 -1
- ipmg-3.1.0/tests/test_metrics.py +162 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/LICENSE +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/setup.cfg +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/__main__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/__init__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/config.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/__init__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/diff.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/discovery.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/engine.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/health.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/ping.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/portscan.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/security.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/exceptions.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/__init__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/file_io.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/incremental.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/notify.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/__init__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/diff_report.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/frames.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/live.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/machine.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/summary.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/ui.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/services/__init__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/services/history_service.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/services/scan_service.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/utils/__init__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/utils/helpers.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/__init__.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/db.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/manager.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/schemas.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/css/app.css +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/index.html +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/api.js +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/app.js +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/charts.js +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/demo.js +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/views.js +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/entry_points.txt +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/requires.txt +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/top_level.txt +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_api_contract.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_config.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_database_history.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_diff.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_diff_report.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_discover.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_engine.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_file_io.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_health.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_history_service.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_incremental.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ipv6.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_live.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_machine_output.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_notify.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_parser.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ping.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ping_command.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_portscan.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_scan_service.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ui.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_utils.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_api.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_db.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_manager.py +0 -0
- {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipmg
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: IP Management & Ping Monitoring CLI Tool
|
|
5
5
|
Author: Sameer Alam
|
|
6
6
|
Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
|
|
@@ -558,6 +558,57 @@ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/ma
|
|
|
558
558
|
covers authentication, errors, and worked `curl` examples; the running server
|
|
559
559
|
also serves interactive docs at `http://127.0.0.1:8080/docs`.
|
|
560
560
|
|
|
561
|
+
### Prometheus metrics
|
|
562
|
+
|
|
563
|
+
`ipmg web --metrics` serves `/metrics` in the Prometheus text format, so the
|
|
564
|
+
results of every scan, whether run from cron with the CLI or from the browser,
|
|
565
|
+
land in the dashboards and alerts you already have. It reports the latest
|
|
566
|
+
completed scan of each source, and it is off unless you pass the flag:
|
|
567
|
+
|
|
568
|
+
```bash
|
|
569
|
+
IPMG_WEB_TOKEN=$(cat /etc/ipmg/token) ipmg web --metrics --no-browser
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
```yaml
|
|
573
|
+
# prometheus.yml: the token is the same one the web UI uses
|
|
574
|
+
scrape_configs:
|
|
575
|
+
- job_name: ipmg
|
|
576
|
+
authorization:
|
|
577
|
+
credentials_file: /etc/prometheus/ipmg-token
|
|
578
|
+
static_configs:
|
|
579
|
+
- targets: ["127.0.0.1:8080"]
|
|
580
|
+
|
|
581
|
+
# an alert rule: a host that stopped answering
|
|
582
|
+
groups:
|
|
583
|
+
- name: ipmg
|
|
584
|
+
rules:
|
|
585
|
+
- alert: HostDown
|
|
586
|
+
expr: ipmg_host_up == 0
|
|
587
|
+
for: 10m
|
|
588
|
+
annotations:
|
|
589
|
+
summary: "{{ $labels.ip }} ({{ $labels.source }}) is not answering ping"
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
| Metric | Labels | Meaning |
|
|
593
|
+
| --- | --- | --- |
|
|
594
|
+
| `ipmg_host_up` | `source`, `ip` | 1 if the host answered, else 0 |
|
|
595
|
+
| `ipmg_host_latency_seconds` | `source`, `ip` | Round-trip time, for hosts that answered |
|
|
596
|
+
| `ipmg_host_open_ports` | `source`, `ip` | Open TCP ports, for scans run with `--scan-ports` |
|
|
597
|
+
| `ipmg_host_info` | `source`, `ip`, `hostname` | Always 1; carries the reverse DNS name |
|
|
598
|
+
| `ipmg_scan_hosts` | `source`, `status` | Hosts in the latest scan, by status |
|
|
599
|
+
| `ipmg_scan_duration_seconds` | `source` | How long the latest scan took |
|
|
600
|
+
| `ipmg_scan_timestamp_seconds` | `source` | When the latest scan finished; alert on `time() - this` to catch a stalled cron job |
|
|
601
|
+
| `ipmg_scans_total` | `source` | Completed scans stored for the source |
|
|
602
|
+
| `ipmg_metrics_sources_omitted` | | Sources left out by `--metrics-sources` |
|
|
603
|
+
| `ipmg_build_info` | `version` | Always 1 |
|
|
604
|
+
|
|
605
|
+
**Cardinality is bounded.** Host series carry only `source` and `ip`, never
|
|
606
|
+
the hostname or status, which change, so a rename does not start a new series.
|
|
607
|
+
Only the `--metrics-sources` most recently scanned sources are exported
|
|
608
|
+
(default 20), and a scan holds at most 65,536 hosts, so the page stays at
|
|
609
|
+
about `sources × hosts × 4` series at most. Give recurring scans a stable
|
|
610
|
+
input (the same file) so each one updates its source rather than adding one.
|
|
611
|
+
|
|
561
612
|
### On a server with no browser
|
|
562
613
|
|
|
563
614
|
On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
|
|
@@ -506,6 +506,57 @@ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/ma
|
|
|
506
506
|
covers authentication, errors, and worked `curl` examples; the running server
|
|
507
507
|
also serves interactive docs at `http://127.0.0.1:8080/docs`.
|
|
508
508
|
|
|
509
|
+
### Prometheus metrics
|
|
510
|
+
|
|
511
|
+
`ipmg web --metrics` serves `/metrics` in the Prometheus text format, so the
|
|
512
|
+
results of every scan, whether run from cron with the CLI or from the browser,
|
|
513
|
+
land in the dashboards and alerts you already have. It reports the latest
|
|
514
|
+
completed scan of each source, and it is off unless you pass the flag:
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
IPMG_WEB_TOKEN=$(cat /etc/ipmg/token) ipmg web --metrics --no-browser
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
```yaml
|
|
521
|
+
# prometheus.yml: the token is the same one the web UI uses
|
|
522
|
+
scrape_configs:
|
|
523
|
+
- job_name: ipmg
|
|
524
|
+
authorization:
|
|
525
|
+
credentials_file: /etc/prometheus/ipmg-token
|
|
526
|
+
static_configs:
|
|
527
|
+
- targets: ["127.0.0.1:8080"]
|
|
528
|
+
|
|
529
|
+
# an alert rule: a host that stopped answering
|
|
530
|
+
groups:
|
|
531
|
+
- name: ipmg
|
|
532
|
+
rules:
|
|
533
|
+
- alert: HostDown
|
|
534
|
+
expr: ipmg_host_up == 0
|
|
535
|
+
for: 10m
|
|
536
|
+
annotations:
|
|
537
|
+
summary: "{{ $labels.ip }} ({{ $labels.source }}) is not answering ping"
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
| Metric | Labels | Meaning |
|
|
541
|
+
| --- | --- | --- |
|
|
542
|
+
| `ipmg_host_up` | `source`, `ip` | 1 if the host answered, else 0 |
|
|
543
|
+
| `ipmg_host_latency_seconds` | `source`, `ip` | Round-trip time, for hosts that answered |
|
|
544
|
+
| `ipmg_host_open_ports` | `source`, `ip` | Open TCP ports, for scans run with `--scan-ports` |
|
|
545
|
+
| `ipmg_host_info` | `source`, `ip`, `hostname` | Always 1; carries the reverse DNS name |
|
|
546
|
+
| `ipmg_scan_hosts` | `source`, `status` | Hosts in the latest scan, by status |
|
|
547
|
+
| `ipmg_scan_duration_seconds` | `source` | How long the latest scan took |
|
|
548
|
+
| `ipmg_scan_timestamp_seconds` | `source` | When the latest scan finished; alert on `time() - this` to catch a stalled cron job |
|
|
549
|
+
| `ipmg_scans_total` | `source` | Completed scans stored for the source |
|
|
550
|
+
| `ipmg_metrics_sources_omitted` | | Sources left out by `--metrics-sources` |
|
|
551
|
+
| `ipmg_build_info` | `version` | Always 1 |
|
|
552
|
+
|
|
553
|
+
**Cardinality is bounded.** Host series carry only `source` and `ip`, never
|
|
554
|
+
the hostname or status, which change, so a rename does not start a new series.
|
|
555
|
+
Only the `--metrics-sources` most recently scanned sources are exported
|
|
556
|
+
(default 20), and a scan holds at most 65,536 hosts, so the page stays at
|
|
557
|
+
about `sources × hosts × 4` series at most. Give recurring scans a stable
|
|
558
|
+
input (the same file) so each one updates its source rather than adding one.
|
|
559
|
+
|
|
509
560
|
### On a server with no browser
|
|
510
561
|
|
|
511
562
|
On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
|
|
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
|
|
|
13
13
|
|
|
14
14
|
[project]
|
|
15
15
|
name = "ipmg"
|
|
16
|
-
version = "3.
|
|
16
|
+
version = "3.1.0" # Managed automatically by semantic-release
|
|
17
17
|
description = "IP Management & Ping Monitoring CLI Tool"
|
|
18
18
|
readme = "README.md"
|
|
19
19
|
requires-python = ">=3.9"
|
|
@@ -27,6 +27,7 @@ from ipmg.infrastructure.notify import (
|
|
|
27
27
|
)
|
|
28
28
|
from ipmg.reporting.diff_report import DIFF_FORMATS
|
|
29
29
|
from ipmg.reporting.live import DEFAULT_REFRESH_S, MAX_REFRESH_S, MIN_REFRESH_S
|
|
30
|
+
from ipmg.reporting.metrics import DEFAULT_MAX_SOURCES
|
|
30
31
|
|
|
31
32
|
PROG = "IPMG - IP Management & Ping Monitoring Tool"
|
|
32
33
|
|
|
@@ -458,6 +459,24 @@ def build_web_parser() -> argparse.ArgumentParser:
|
|
|
458
459
|
action="store_true",
|
|
459
460
|
help="Do not open IPMG Web in a browser automatically.",
|
|
460
461
|
)
|
|
462
|
+
parser.add_argument(
|
|
463
|
+
"--metrics",
|
|
464
|
+
action="store_true",
|
|
465
|
+
help=(
|
|
466
|
+
"Serve Prometheus metrics at /metrics for the latest completed scan of "
|
|
467
|
+
"each source (off by default; needs the access token)."
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--metrics-sources",
|
|
472
|
+
type=int,
|
|
473
|
+
default=DEFAULT_MAX_SOURCES,
|
|
474
|
+
metavar="N",
|
|
475
|
+
help=(
|
|
476
|
+
"Export only the N most recently scanned sources, which bounds the "
|
|
477
|
+
f"number of series (default: {DEFAULT_MAX_SOURCES})."
|
|
478
|
+
),
|
|
479
|
+
)
|
|
461
480
|
_add_database_argument(parser)
|
|
462
481
|
parser.add_argument("--verbose", action="store_true")
|
|
463
482
|
return parser
|
|
@@ -13,7 +13,7 @@ import threading
|
|
|
13
13
|
from contextlib import contextmanager
|
|
14
14
|
from datetime import datetime
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
16
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
17
17
|
|
|
18
18
|
from ipmg.core.diff import HostSnapshot, ScanRef, ip_sort_key
|
|
19
19
|
from ipmg.core.engine import HostResult
|
|
@@ -299,6 +299,22 @@ class Database:
|
|
|
299
299
|
with self._session() as conn:
|
|
300
300
|
return [dict(row) for row in conn.execute(query, params).fetchall()]
|
|
301
301
|
|
|
302
|
+
def latest_completed_scans(self, limit: int) -> Tuple[List[Dict[str, Any]], int]:
|
|
303
|
+
"""The newest completed scan of each source, most recent source first.
|
|
304
|
+
|
|
305
|
+
Returns at most ``limit`` scans, each with a ``completed_count`` of the
|
|
306
|
+
completed scans its source has, plus how many sources exist in all, so
|
|
307
|
+
a caller can say how many it left out.
|
|
308
|
+
"""
|
|
309
|
+
with self._session() as conn:
|
|
310
|
+
rows = conn.execute(
|
|
311
|
+
"SELECT s.*, latest.completed_count FROM scans s JOIN ("
|
|
312
|
+
" SELECT source, MAX(id) AS id, COUNT(*) AS completed_count "
|
|
313
|
+
" FROM scans WHERE status = 'complete' GROUP BY source"
|
|
314
|
+
") latest ON s.id = latest.id ORDER BY s.id DESC"
|
|
315
|
+
).fetchall()
|
|
316
|
+
return [self._scan_dict(row) for row in rows[:limit]], len(rows)
|
|
317
|
+
|
|
302
318
|
# ------------------------------------------------------- aggregates
|
|
303
319
|
|
|
304
320
|
def overview(self) -> Dict[str, Any]:
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Prometheus metrics for the most recent completed scan of each source.
|
|
2
|
+
|
|
3
|
+
Rendered by hand in the text exposition format (version 0.0.4), so serving
|
|
4
|
+
``/metrics`` adds no dependency. Every per-host series is labelled by
|
|
5
|
+
``source`` and ``ip`` only; the hostname, which changes, lives on a separate
|
|
6
|
+
``ipmg_host_info`` series so a rename does not start a new latency series.
|
|
7
|
+
|
|
8
|
+
Cardinality is bounded by what is in the history database: one series per
|
|
9
|
+
host per metric for the newest completed scan of each of the ``max_sources``
|
|
10
|
+
most recently scanned sources, and a scan holds at most 65,536 hosts.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from ipmg import __version__
|
|
19
|
+
from ipmg.core.portscan import decode_ports
|
|
20
|
+
from ipmg.infrastructure.database import Database
|
|
21
|
+
|
|
22
|
+
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
|
|
23
|
+
|
|
24
|
+
#: Sources exported by default: the most recently scanned ones.
|
|
25
|
+
DEFAULT_MAX_SOURCES = 20
|
|
26
|
+
|
|
27
|
+
Labels = Sequence[Tuple[str, str]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _escape(value: str) -> str:
|
|
31
|
+
"""Escape a label value as the exposition format requires."""
|
|
32
|
+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _number(value: float) -> str:
|
|
36
|
+
return repr(float(value)) if not float(value).is_integer() else str(int(value))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Exposition:
|
|
40
|
+
"""Collects samples per metric, then writes each family under one header."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._families: Dict[str, Tuple[str, str, List[str]]] = {}
|
|
44
|
+
|
|
45
|
+
def declare(self, name: str, kind: str, help_text: str) -> None:
|
|
46
|
+
self._families[name] = (kind, help_text, [])
|
|
47
|
+
|
|
48
|
+
def sample(self, name: str, labels: Labels, value: float) -> None:
|
|
49
|
+
rendered = ",".join(f'{key}="{_escape(str(val))}"' for key, val in labels)
|
|
50
|
+
series = f"{name}{{{rendered}}}" if rendered else name
|
|
51
|
+
self._families[name][2].append(f"{series} {_number(value)}")
|
|
52
|
+
|
|
53
|
+
def render(self) -> str:
|
|
54
|
+
lines: List[str] = []
|
|
55
|
+
for name, (kind, help_text, samples) in self._families.items():
|
|
56
|
+
lines.append(f"# HELP {name} {help_text}")
|
|
57
|
+
lines.append(f"# TYPE {name} {kind}")
|
|
58
|
+
lines.extend(samples)
|
|
59
|
+
return "\n".join(lines) + "\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_FAMILIES = (
|
|
63
|
+
("ipmg_build_info", "gauge", "IPMG version serving these metrics (always 1)."),
|
|
64
|
+
("ipmg_host_up", "gauge", "1 if the host answered in its source's latest scan, else 0."),
|
|
65
|
+
(
|
|
66
|
+
"ipmg_host_latency_seconds",
|
|
67
|
+
"gauge",
|
|
68
|
+
"Round-trip time in the latest scan, hosts that answered.",
|
|
69
|
+
),
|
|
70
|
+
("ipmg_host_open_ports", "gauge", "Open TCP ports found, for scans that checked ports."),
|
|
71
|
+
("ipmg_host_info", "gauge", "The host's reverse DNS name in the latest scan (always 1)."),
|
|
72
|
+
("ipmg_scan_hosts", "gauge", "Hosts in the source's latest scan, by status."),
|
|
73
|
+
("ipmg_scan_duration_seconds", "gauge", "How long the source's latest scan took."),
|
|
74
|
+
("ipmg_scan_timestamp_seconds", "gauge", "When the source's latest scan finished (Unix time)."),
|
|
75
|
+
("ipmg_scans_total", "counter", "Completed scans stored for the source."),
|
|
76
|
+
("ipmg_metrics_sources_omitted", "gauge", "Sources left out by the --metrics-sources limit."),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _finished_at(scan: Dict) -> Optional[float]:
|
|
81
|
+
try:
|
|
82
|
+
return datetime.fromisoformat(scan["finished_at"]).timestamp()
|
|
83
|
+
except (KeyError, TypeError, ValueError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _host_samples(out: _Exposition, source: str, rows: Iterable[Dict], ports: bool) -> None:
|
|
88
|
+
for row in rows:
|
|
89
|
+
labels = (("source", source), ("ip", row["ip"]))
|
|
90
|
+
out.sample("ipmg_host_up", labels, 1 if row["status"] == "Active" else 0)
|
|
91
|
+
if row["latency"] is not None:
|
|
92
|
+
out.sample("ipmg_host_latency_seconds", labels, row["latency"] / 1000)
|
|
93
|
+
if ports:
|
|
94
|
+
out.sample("ipmg_host_open_ports", labels, len(decode_ports(row["open_ports"])))
|
|
95
|
+
if row["hostname"]:
|
|
96
|
+
out.sample("ipmg_host_info", (*labels, ("hostname", row["hostname"])), 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_metrics(database: Database, max_sources: int = DEFAULT_MAX_SOURCES) -> str:
|
|
100
|
+
"""The metrics page for ``database``, in Prometheus text format."""
|
|
101
|
+
out = _Exposition()
|
|
102
|
+
for name, kind, help_text in _FAMILIES:
|
|
103
|
+
out.declare(name, kind, help_text)
|
|
104
|
+
out.sample("ipmg_build_info", (("version", __version__),), 1)
|
|
105
|
+
|
|
106
|
+
scans, source_count = database.latest_completed_scans(max_sources)
|
|
107
|
+
for scan in scans:
|
|
108
|
+
source = scan["source"]
|
|
109
|
+
rows = database.get_results(scan["id"])
|
|
110
|
+
_host_samples(out, source, rows, bool(scan["config"].get("scan_ports")))
|
|
111
|
+
|
|
112
|
+
counts: Dict[str, int] = {}
|
|
113
|
+
for row in rows:
|
|
114
|
+
counts[row["status"]] = counts.get(row["status"], 0) + 1
|
|
115
|
+
for status, count in sorted(counts.items()):
|
|
116
|
+
out.sample("ipmg_scan_hosts", (("source", source), ("status", status)), count)
|
|
117
|
+
|
|
118
|
+
out.sample("ipmg_scan_duration_seconds", (("source", source),), scan["duration_s"] or 0)
|
|
119
|
+
finished = _finished_at(scan)
|
|
120
|
+
if finished is not None:
|
|
121
|
+
out.sample("ipmg_scan_timestamp_seconds", (("source", source),), finished)
|
|
122
|
+
out.sample("ipmg_scans_total", (("source", source),), scan["completed_count"])
|
|
123
|
+
|
|
124
|
+
out.sample("ipmg_metrics_sources_omitted", (), max(source_count - len(scans), 0))
|
|
125
|
+
return out.render()
|
|
@@ -568,8 +568,31 @@ def _install_openapi(app: FastAPI) -> None:
|
|
|
568
568
|
app.openapi = openapi # type: ignore[method-assign]
|
|
569
569
|
|
|
570
570
|
|
|
571
|
-
def
|
|
572
|
-
|
|
571
|
+
def _register_metrics_route(app: FastAPI, database: Database, token: str, max_sources: int) -> None:
|
|
572
|
+
# Outside /api/v1 because /metrics is where Prometheus looks, but behind
|
|
573
|
+
# the same token: a scrape config sends it as a Bearer credential.
|
|
574
|
+
# Imported here: the renderer is only needed when --metrics turns it on.
|
|
575
|
+
from ipmg.reporting.metrics import CONTENT_TYPE as METRICS_CONTENT_TYPE, render_metrics
|
|
576
|
+
|
|
577
|
+
@app.get(
|
|
578
|
+
"/metrics",
|
|
579
|
+
dependencies=[Depends(_require_token(token))],
|
|
580
|
+
include_in_schema=False,
|
|
581
|
+
)
|
|
582
|
+
def metrics() -> Response:
|
|
583
|
+
return Response(render_metrics(database, max_sources), media_type=METRICS_CONTENT_TYPE)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def create_app(
|
|
587
|
+
db: Optional[Database] = None,
|
|
588
|
+
token: Optional[str] = None,
|
|
589
|
+
metrics_sources: Optional[int] = None,
|
|
590
|
+
) -> FastAPI:
|
|
591
|
+
"""Build the app. ``token`` guards every API route; a random one is made if omitted.
|
|
592
|
+
|
|
593
|
+
``metrics_sources`` turns on ``/metrics`` for that many of the most
|
|
594
|
+
recently scanned sources; left as None, the endpoint does not exist.
|
|
595
|
+
"""
|
|
573
596
|
database = db if db is not None else Database(DEFAULT_DB_PATH)
|
|
574
597
|
token = token or secrets.token_urlsafe(32)
|
|
575
598
|
manager = ScanManager(database)
|
|
@@ -601,6 +624,8 @@ def create_app(db: Optional[Database] = None, token: Optional[str] = None) -> Fa
|
|
|
601
624
|
app.include_router(api)
|
|
602
625
|
|
|
603
626
|
_register_websocket(app, manager, token)
|
|
627
|
+
if metrics_sources is not None:
|
|
628
|
+
_register_metrics_route(app, database, token, metrics_sources)
|
|
604
629
|
_register_error_handlers(app)
|
|
605
630
|
_install_openapi(app)
|
|
606
631
|
|
|
@@ -69,10 +69,11 @@ def run_dashboard(
|
|
|
69
69
|
port: int = 8080,
|
|
70
70
|
open_browser: bool = True,
|
|
71
71
|
db_path: Optional[str] = None,
|
|
72
|
+
metrics_sources: Optional[int] = None,
|
|
72
73
|
) -> None:
|
|
73
74
|
database = Database(Path(db_path) if db_path else DEFAULT_DB_PATH)
|
|
74
75
|
token = _access_token()
|
|
75
|
-
app = create_app(database, token)
|
|
76
|
+
app = create_app(database, token, metrics_sources=metrics_sources)
|
|
76
77
|
# The token goes in the fragment, which browsers never send to the server,
|
|
77
78
|
# so it stays out of access logs and proxies.
|
|
78
79
|
url = f"http://{_display_host(host)}:{port}/#token={token}"
|
|
@@ -94,6 +95,11 @@ def run_dashboard(
|
|
|
94
95
|
threading.Timer(1.0, webbrowser.open, args=(url,)).start()
|
|
95
96
|
|
|
96
97
|
ui.note("The link carries this session's access token; the API refuses requests without it.")
|
|
98
|
+
if metrics_sources is not None:
|
|
99
|
+
ui.note(
|
|
100
|
+
f"Prometheus metrics at http://{_display_host(host)}:{port}/metrics "
|
|
101
|
+
"(send the token as a Bearer credential; pin it with IPMG_WEB_TOKEN)."
|
|
102
|
+
)
|
|
97
103
|
if _is_loopback(host):
|
|
98
104
|
ui.note(
|
|
99
105
|
f"Remote access: ssh -L {port}:127.0.0.1:{port} user@this-host, then open the link."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipmg
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: IP Management & Ping Monitoring CLI Tool
|
|
5
5
|
Author: Sameer Alam
|
|
6
6
|
Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
|
|
@@ -558,6 +558,57 @@ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/ma
|
|
|
558
558
|
covers authentication, errors, and worked `curl` examples; the running server
|
|
559
559
|
also serves interactive docs at `http://127.0.0.1:8080/docs`.
|
|
560
560
|
|
|
561
|
+
### Prometheus metrics
|
|
562
|
+
|
|
563
|
+
`ipmg web --metrics` serves `/metrics` in the Prometheus text format, so the
|
|
564
|
+
results of every scan, whether run from cron with the CLI or from the browser,
|
|
565
|
+
land in the dashboards and alerts you already have. It reports the latest
|
|
566
|
+
completed scan of each source, and it is off unless you pass the flag:
|
|
567
|
+
|
|
568
|
+
```bash
|
|
569
|
+
IPMG_WEB_TOKEN=$(cat /etc/ipmg/token) ipmg web --metrics --no-browser
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
```yaml
|
|
573
|
+
# prometheus.yml: the token is the same one the web UI uses
|
|
574
|
+
scrape_configs:
|
|
575
|
+
- job_name: ipmg
|
|
576
|
+
authorization:
|
|
577
|
+
credentials_file: /etc/prometheus/ipmg-token
|
|
578
|
+
static_configs:
|
|
579
|
+
- targets: ["127.0.0.1:8080"]
|
|
580
|
+
|
|
581
|
+
# an alert rule: a host that stopped answering
|
|
582
|
+
groups:
|
|
583
|
+
- name: ipmg
|
|
584
|
+
rules:
|
|
585
|
+
- alert: HostDown
|
|
586
|
+
expr: ipmg_host_up == 0
|
|
587
|
+
for: 10m
|
|
588
|
+
annotations:
|
|
589
|
+
summary: "{{ $labels.ip }} ({{ $labels.source }}) is not answering ping"
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
| Metric | Labels | Meaning |
|
|
593
|
+
| --- | --- | --- |
|
|
594
|
+
| `ipmg_host_up` | `source`, `ip` | 1 if the host answered, else 0 |
|
|
595
|
+
| `ipmg_host_latency_seconds` | `source`, `ip` | Round-trip time, for hosts that answered |
|
|
596
|
+
| `ipmg_host_open_ports` | `source`, `ip` | Open TCP ports, for scans run with `--scan-ports` |
|
|
597
|
+
| `ipmg_host_info` | `source`, `ip`, `hostname` | Always 1; carries the reverse DNS name |
|
|
598
|
+
| `ipmg_scan_hosts` | `source`, `status` | Hosts in the latest scan, by status |
|
|
599
|
+
| `ipmg_scan_duration_seconds` | `source` | How long the latest scan took |
|
|
600
|
+
| `ipmg_scan_timestamp_seconds` | `source` | When the latest scan finished; alert on `time() - this` to catch a stalled cron job |
|
|
601
|
+
| `ipmg_scans_total` | `source` | Completed scans stored for the source |
|
|
602
|
+
| `ipmg_metrics_sources_omitted` | | Sources left out by `--metrics-sources` |
|
|
603
|
+
| `ipmg_build_info` | `version` | Always 1 |
|
|
604
|
+
|
|
605
|
+
**Cardinality is bounded.** Host series carry only `source` and `ip`, never
|
|
606
|
+
the hostname or status, which change, so a rename does not start a new series.
|
|
607
|
+
Only the `--metrics-sources` most recently scanned sources are exported
|
|
608
|
+
(default 20), and a scan holds at most 65,536 hosts, so the page stays at
|
|
609
|
+
about `sources × hosts × 4` series at most. Give recurring scans a stable
|
|
610
|
+
input (the same file) so each one updates its source rather than adding one.
|
|
611
|
+
|
|
561
612
|
### On a server with no browser
|
|
562
613
|
|
|
563
614
|
On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
|
|
@@ -32,6 +32,7 @@ src/ipmg/reporting/diff_report.py
|
|
|
32
32
|
src/ipmg/reporting/frames.py
|
|
33
33
|
src/ipmg/reporting/live.py
|
|
34
34
|
src/ipmg/reporting/machine.py
|
|
35
|
+
src/ipmg/reporting/metrics.py
|
|
35
36
|
src/ipmg/reporting/summary.py
|
|
36
37
|
src/ipmg/reporting/ui.py
|
|
37
38
|
src/ipmg/services/__init__.py
|
|
@@ -67,6 +68,7 @@ tests/test_incremental.py
|
|
|
67
68
|
tests/test_ipv6.py
|
|
68
69
|
tests/test_live.py
|
|
69
70
|
tests/test_machine_output.py
|
|
71
|
+
tests/test_metrics.py
|
|
70
72
|
tests/test_notify.py
|
|
71
73
|
tests/test_parser.py
|
|
72
74
|
tests/test_ping.py
|
|
@@ -208,7 +208,15 @@ def test_both_spellings_of_web_start_it(argv, monkeypatch):
|
|
|
208
208
|
calls = web_calls(monkeypatch)
|
|
209
209
|
|
|
210
210
|
assert commands.run(argv) == commands.EXIT_OK
|
|
211
|
-
assert calls == [
|
|
211
|
+
assert calls == [
|
|
212
|
+
{
|
|
213
|
+
"host": "127.0.0.1",
|
|
214
|
+
"port": 8080,
|
|
215
|
+
"open_browser": True,
|
|
216
|
+
"db_path": None,
|
|
217
|
+
"metrics_sources": None,
|
|
218
|
+
}
|
|
219
|
+
]
|
|
212
220
|
|
|
213
221
|
|
|
214
222
|
def test_web_flag_still_takes_the_web_options(monkeypatch):
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The Prometheus /metrics endpoint and the text it serves."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from ipmg import __version__
|
|
9
|
+
from ipmg.cli.parser import build_web_parser
|
|
10
|
+
from ipmg.core.engine import HostResult, ScanConfig
|
|
11
|
+
from ipmg.infrastructure.database import Database
|
|
12
|
+
from ipmg.reporting.metrics import CONTENT_TYPE, render_metrics
|
|
13
|
+
from ipmg.services.history_service import HistoryService
|
|
14
|
+
from ipmg.web.app import create_app
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture()
|
|
18
|
+
def database(tmp_path):
|
|
19
|
+
return Database(tmp_path / "metrics.db")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def record(database, source, results, scan_ports=False, duration=2.5):
|
|
23
|
+
return HistoryService(database).record_scan(
|
|
24
|
+
source=source,
|
|
25
|
+
results=results,
|
|
26
|
+
config=ScanConfig(scan_ports=scan_ports),
|
|
27
|
+
duration_s=duration,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def samples(text):
|
|
32
|
+
"""{series: value} for every sample line, ignoring HELP and TYPE."""
|
|
33
|
+
found = {}
|
|
34
|
+
for line in text.splitlines():
|
|
35
|
+
if line and not line.startswith("#"):
|
|
36
|
+
series, value = line.rsplit(" ", 1)
|
|
37
|
+
found[series] = float(value)
|
|
38
|
+
return found
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------ content
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_hosts_are_reported_from_the_latest_scan_of_each_source(database):
|
|
45
|
+
record(database, "targets.txt", [HostResult("10.0.0.1", "Timeout", None)])
|
|
46
|
+
record(
|
|
47
|
+
database,
|
|
48
|
+
"targets.txt",
|
|
49
|
+
[HostResult("10.0.0.1", "Active", 12.5, "gw.lan"), HostResult("10.0.0.2", "Timeout", None)],
|
|
50
|
+
duration=4.0,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
metrics = samples(render_metrics(database))
|
|
54
|
+
|
|
55
|
+
assert metrics['ipmg_host_up{source="targets.txt",ip="10.0.0.1"}'] == 1
|
|
56
|
+
assert metrics['ipmg_host_up{source="targets.txt",ip="10.0.0.2"}'] == 0
|
|
57
|
+
assert metrics['ipmg_host_latency_seconds{source="targets.txt",ip="10.0.0.1"}'] == 0.0125
|
|
58
|
+
assert 'ipmg_host_latency_seconds{source="targets.txt",ip="10.0.0.2"}' not in metrics
|
|
59
|
+
assert metrics['ipmg_host_info{source="targets.txt",ip="10.0.0.1",hostname="gw.lan"}'] == 1
|
|
60
|
+
assert metrics['ipmg_scan_hosts{source="targets.txt",status="Active"}'] == 1
|
|
61
|
+
assert metrics['ipmg_scan_hosts{source="targets.txt",status="Timeout"}'] == 1
|
|
62
|
+
assert metrics['ipmg_scan_duration_seconds{source="targets.txt"}'] == 4.0
|
|
63
|
+
assert metrics['ipmg_scans_total{source="targets.txt"}'] == 2
|
|
64
|
+
assert metrics['ipmg_scan_timestamp_seconds{source="targets.txt"}'] > 1.7e9
|
|
65
|
+
assert metrics[f'ipmg_build_info{{version="{__version__}"}}'] == 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_each_source_keeps_its_own_latest_scan(database):
|
|
69
|
+
record(database, "office.txt", [HostResult("10.0.0.1", "Active", 1.0)])
|
|
70
|
+
record(database, "lab.txt", [HostResult("10.0.0.1", "Timeout", None)])
|
|
71
|
+
|
|
72
|
+
metrics = samples(render_metrics(database))
|
|
73
|
+
|
|
74
|
+
assert metrics['ipmg_host_up{source="office.txt",ip="10.0.0.1"}'] == 1
|
|
75
|
+
assert metrics['ipmg_host_up{source="lab.txt",ip="10.0.0.1"}'] == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_open_ports_only_appear_for_scans_that_checked_ports(database):
|
|
79
|
+
record(
|
|
80
|
+
database, "ports.txt", [HostResult("10.0.0.1", "Active", 1.0, open_ports=(22, 443))], True
|
|
81
|
+
)
|
|
82
|
+
record(database, "plain.txt", [HostResult("10.0.0.2", "Active", 1.0)])
|
|
83
|
+
|
|
84
|
+
metrics = samples(render_metrics(database))
|
|
85
|
+
|
|
86
|
+
assert metrics['ipmg_host_open_ports{source="ports.txt",ip="10.0.0.1"}'] == 2
|
|
87
|
+
assert not any(key.startswith('ipmg_host_open_ports{source="plain.txt"') for key in metrics)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_running_and_failed_scans_are_not_reported(database):
|
|
91
|
+
running = database.create_scan("live.txt", 1, {})
|
|
92
|
+
database.add_result(running, HostResult("10.0.0.1", "Active", 1.0))
|
|
93
|
+
|
|
94
|
+
assert "live.txt" not in render_metrics(database)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_the_source_limit_bounds_the_series_and_says_what_it_left_out(database):
|
|
98
|
+
for name in ("a.txt", "b.txt", "c.txt"):
|
|
99
|
+
record(database, name, [HostResult("10.0.0.1", "Active", 1.0)])
|
|
100
|
+
|
|
101
|
+
text = render_metrics(database, max_sources=2)
|
|
102
|
+
|
|
103
|
+
assert 'source="c.txt"' in text and 'source="b.txt"' in text # the newest two
|
|
104
|
+
assert 'source="a.txt"' not in text
|
|
105
|
+
assert samples(text)["ipmg_metrics_sources_omitted"] == 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_label_values_are_escaped(database):
|
|
109
|
+
record(database, 'odd "name"\\path\nnext', [HostResult("10.0.0.1", "Active", 1.0)])
|
|
110
|
+
|
|
111
|
+
assert 'source="odd \\"name\\"\\\\path\\nnext"' in render_metrics(database)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_every_family_is_declared_once_even_when_empty(database):
|
|
115
|
+
text = render_metrics(database)
|
|
116
|
+
|
|
117
|
+
assert len(re.findall(r"^# TYPE ipmg_host_up gauge$", text, re.MULTILINE)) == 1
|
|
118
|
+
assert "# TYPE ipmg_scans_total counter" in text
|
|
119
|
+
assert text.endswith("\n")
|
|
120
|
+
assert samples(text)["ipmg_metrics_sources_omitted"] == 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_ipv6_hosts_are_labelled_as_written(database):
|
|
124
|
+
record(database, "v6.txt", [HostResult("fe80::1%eth0", "Active", 1.0)])
|
|
125
|
+
|
|
126
|
+
assert 'ipmg_host_up{source="v6.txt",ip="fe80::1%eth0"} 1' in render_metrics(database)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ endpoint
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_metrics_are_off_unless_asked_for(database):
|
|
133
|
+
with TestClient(create_app(database, token="t" * 20)) as client:
|
|
134
|
+
response = client.get("/metrics", headers={"Authorization": "Bearer " + "t" * 20})
|
|
135
|
+
|
|
136
|
+
assert response.status_code == 404
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_the_endpoint_serves_prometheus_text_with_the_token(database):
|
|
140
|
+
record(database, "targets.txt", [HostResult("10.0.0.1", "Active", 1.0)])
|
|
141
|
+
app = create_app(database, token="t" * 20, metrics_sources=5)
|
|
142
|
+
|
|
143
|
+
with TestClient(app) as client:
|
|
144
|
+
anonymous = client.get("/metrics")
|
|
145
|
+
scraped = client.get("/metrics", headers={"Authorization": "Bearer " + "t" * 20})
|
|
146
|
+
|
|
147
|
+
assert anonymous.status_code == 401
|
|
148
|
+
assert scraped.status_code == 200
|
|
149
|
+
assert scraped.headers["content-type"] == CONTENT_TYPE
|
|
150
|
+
assert 'ipmg_host_up{source="targets.txt",ip="10.0.0.1"} 1' in scraped.text
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_the_endpoint_stays_out_of_the_api_schema(database):
|
|
154
|
+
app = create_app(database, token="t" * 20, metrics_sources=5)
|
|
155
|
+
|
|
156
|
+
assert "/metrics" not in app.openapi()["paths"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_web_parser_metrics_flags():
|
|
160
|
+
assert build_web_parser().parse_args([]).metrics is False
|
|
161
|
+
args = build_web_parser().parse_args(["--metrics", "--metrics-sources", "3"])
|
|
162
|
+
assert (args.metrics, args.metrics_sources) == (True, 3)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|