ipmg 2.2.0__tar.gz → 2.3.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.
Files changed (79) hide show
  1. {ipmg-2.2.0/src/ipmg.egg-info → ipmg-2.3.0}/PKG-INFO +50 -6
  2. {ipmg-2.2.0 → ipmg-2.3.0}/README.md +48 -5
  3. {ipmg-2.2.0 → ipmg-2.3.0}/pyproject.toml +5 -1
  4. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/__init__.py +1 -1
  5. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/cli/commands.py +11 -0
  6. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/cli/parser.py +118 -1
  7. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/core/diff.py +6 -0
  8. ipmg-2.3.0/src/ipmg/core/health.py +64 -0
  9. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/exceptions.py +4 -0
  10. ipmg-2.3.0/src/ipmg/infrastructure/notify.py +403 -0
  11. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/services/scan_service.py +27 -1
  12. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/app.py +218 -39
  13. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/manager.py +18 -16
  14. ipmg-2.3.0/src/ipmg/web/schemas.py +239 -0
  15. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/server.py +1 -1
  16. {ipmg-2.2.0 → ipmg-2.3.0/src/ipmg.egg-info}/PKG-INFO +50 -6
  17. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg.egg-info/SOURCES.txt +6 -0
  18. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg.egg-info/requires.txt +1 -0
  19. ipmg-2.3.0/tests/test_api_contract.py +153 -0
  20. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_commands.py +50 -0
  21. ipmg-2.3.0/tests/test_health.py +63 -0
  22. ipmg-2.3.0/tests/test_notify.py +520 -0
  23. ipmg-2.3.0/tests/test_parser.py +140 -0
  24. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_scan_service.py +140 -0
  25. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_web_api.py +54 -0
  26. ipmg-2.2.0/tests/test_parser.py +0 -67
  27. {ipmg-2.2.0 → ipmg-2.3.0}/LICENSE +0 -0
  28. {ipmg-2.2.0 → ipmg-2.3.0}/setup.cfg +0 -0
  29. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/__main__.py +0 -0
  30. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/cli/__init__.py +0 -0
  31. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/core/__init__.py +0 -0
  32. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/core/discovery.py +0 -0
  33. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/core/engine.py +0 -0
  34. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/core/ping.py +0 -0
  35. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/core/portscan.py +0 -0
  36. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/core/security.py +0 -0
  37. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/infrastructure/__init__.py +0 -0
  38. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/infrastructure/database.py +0 -0
  39. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/infrastructure/file_io.py +0 -0
  40. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/infrastructure/incremental.py +0 -0
  41. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/reporting/__init__.py +0 -0
  42. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/reporting/diff_report.py +0 -0
  43. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/reporting/frames.py +0 -0
  44. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/reporting/live.py +0 -0
  45. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/reporting/summary.py +0 -0
  46. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/reporting/ui.py +0 -0
  47. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/services/__init__.py +0 -0
  48. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/services/history_service.py +0 -0
  49. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/utils/__init__.py +0 -0
  50. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/utils/helpers.py +0 -0
  51. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/__init__.py +0 -0
  52. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/db.py +0 -0
  53. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/static/css/app.css +0 -0
  54. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/static/index.html +0 -0
  55. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/static/js/api.js +0 -0
  56. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/static/js/app.js +0 -0
  57. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/static/js/charts.js +0 -0
  58. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/static/js/demo.js +0 -0
  59. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg/web/static/js/views.js +0 -0
  60. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
  61. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg.egg-info/entry_points.txt +0 -0
  62. {ipmg-2.2.0 → ipmg-2.3.0}/src/ipmg.egg-info/top_level.txt +0 -0
  63. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_database_history.py +0 -0
  64. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_diff.py +0 -0
  65. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_diff_report.py +0 -0
  66. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_discover.py +0 -0
  67. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_engine.py +0 -0
  68. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_file_io.py +0 -0
  69. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_history_service.py +0 -0
  70. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_incremental.py +0 -0
  71. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_live.py +0 -0
  72. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_ping.py +0 -0
  73. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_ping_command.py +0 -0
  74. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_portscan.py +0 -0
  75. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_ui.py +0 -0
  76. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_utils.py +0 -0
  77. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_web_db.py +0 -0
  78. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_web_manager.py +0 -0
  79. {ipmg-2.2.0 → ipmg-2.3.0}/tests/test_web_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipmg
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -33,6 +33,7 @@ Requires-Dist: pandas>=2.2.2
33
33
  Requires-Dist: openpyxl>=3.1
34
34
  Requires-Dist: rich>=13.0
35
35
  Requires-Dist: fastapi>=0.110
36
+ Requires-Dist: pydantic>=2.7
36
37
  Requires-Dist: uvicorn>=0.27
37
38
  Requires-Dist: websockets>=12
38
39
  Requires-Dist: python-multipart>=0.0.9
@@ -390,6 +391,21 @@ ipmg diff --fail-on-change # exit 2 when anything changed (CI)
390
391
  ipmg history --limit 10 # list stored scans
391
392
  ```
392
393
 
394
+ To hear about changes without watching the terminal, send them to Slack,
395
+ Microsoft Teams, any JSON webhook, or email. Any `--notify-*` flag turns on
396
+ `--compare`, and with `--interval` every pass that changes something alerts:
397
+
398
+ ```bash
399
+ ipmg --input targets.txt --interval 15 --notify-slack # URL from $IPMG_NOTIFY_SLACK
400
+ ipmg --input targets.txt --notify-email ops@example.com --smtp-host smtp.example.com
401
+ ipmg diff --notify-webhook https://ops.example.com/ipmg --notify-severity critical
402
+ ```
403
+
404
+ Only changes at or above `--notify-severity` (default `warning`) trigger a
405
+ notification. A notification that fails is reported but never fails the scan.
406
+ Secrets such as webhook URLs and `IPMG_SMTP_PASSWORD` can come from environment
407
+ variables; see [Notifications](docs/COMMANDS.md#notifications) for all of them.
408
+
393
409
  What counts as a change:
394
410
 
395
411
  | Change | Severity | Meaning |
@@ -422,6 +438,11 @@ source**, so file-based and `--discover` runs do not get mixed up. Pass
422
438
  | `--latency-threshold` | `5` | Minimum latency delta in ms |
423
439
  | `--latency-pct` | `25` | Minimum relative latency change |
424
440
  | `--fail-on-change` | off | `ipmg diff` exits 2 when changes are found |
441
+ | `--notify-webhook` | off | POST the change report as JSON to a URL |
442
+ | `--notify-slack` | off | Post changes to a Slack incoming webhook |
443
+ | `--notify-teams` | off | Post changes to a Microsoft Teams Workflows webhook |
444
+ | `--notify-email` | off | Email the Markdown change report (with `--smtp-host`, `--smtp-port`, `--smtp-security`, `--smtp-user`, `--smtp-from`) |
445
+ | `--notify-severity` | `warning` | Only notify for changes at least this severe |
425
446
 
426
447
  ---
427
448
 
@@ -462,6 +483,14 @@ package, nothing is loaded from a CDN. It gives you:
462
483
  | `--no-browser` | off | Don't open the browser automatically |
463
484
  | `--db` | `~/.ipmg/dashboard.db` | History database location |
464
485
 
486
+ ### Scripting IPMG Web
487
+
488
+ Everything the dashboard does goes through a documented, versioned REST API
489
+ under `/api/v1`, so you can start scans, fetch results, and compare scans from
490
+ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)
491
+ covers authentication, errors, and worked `curl` examples; the running server
492
+ also serves interactive docs at `http://127.0.0.1:8080/docs`.
493
+
465
494
  ### On a server with no browser
466
495
 
467
496
  On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
@@ -617,12 +646,21 @@ them — so piping IPMG into a file or a log gives you clean text.
617
646
  | `--stream-refresh` | `0.25` | Seconds between progress-bar redraws while streaming (0.05-5) |
618
647
  | `--verbose` | off | Debug logging |
619
648
 
649
+ **Exit status**
650
+
651
+ | Flag | Default | Description |
652
+ | --- | --- | --- |
653
+ | `--fail-on-down` | off | Exit 3 if any target is not `Active` (reports are still written) |
654
+ | `--min-active` | off | Exit 3 if fewer than this percentage of targets are `Active` |
655
+
620
656
  **History and changes** — see [Change detection](#change-detection) for
621
657
  `--compare`, `--no-history`, `--db`, `--diff-formats`, `--diff-output`,
622
- `--latency-threshold`, `--latency-pct`, and `--fail-on-change`.
658
+ `--latency-threshold`, `--latency-pct`, `--fail-on-change`, and the
659
+ `--notify-*` flags.
623
660
 
624
661
  Exit codes: `0` success, `1` error, `2` changes detected
625
- (`ipmg diff --fail-on-change`), `130` interrupted.
662
+ (`ipmg diff --fail-on-change`), `3` hosts down (`--fail-on-down`,
663
+ `--min-active`), `130` interrupted.
626
664
 
627
665
  ---
628
666
 
@@ -643,12 +681,15 @@ itself is hardened accordingly:
643
681
  - Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
644
682
  so a bad input file cannot exhaust memory
645
683
  - All database access uses parameterized SQL
684
+ - The only outbound requests IPMG makes are the notifications you ask for
685
+ with a `--notify-*` flag. Their URLs and the SMTP password can come from
686
+ environment variables, and error messages never repeat them
646
687
 
647
688
  If you bind to a non-local address with `--host`, the token still guards the
648
689
  API. It travels over plain HTTP, though, so use an SSH tunnel or a reverse
649
690
  proxy with TLS on any network you don't trust.
650
691
 
651
- Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
692
+ Found a vulnerability? See [SECURITY.md](.github/SECURITY.md) for how to report it.
652
693
 
653
694
  ---
654
695
 
@@ -657,6 +698,9 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
657
698
  - **[Command reference](https://github.com/sameeralam3127/ipmg/blob/main/docs/COMMANDS.md)** —
658
699
  every command and flag with copy-paste examples, a safe session that tries
659
700
  everything on your own machine, exit codes, and error messages
701
+ - **[Web API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)** —
702
+ script IPMG Web over HTTP: start scans, fetch results, compare scans,
703
+ and follow live events
660
704
  - **[Troubleshooting](https://github.com/sameeralam3127/ipmg/blob/main/docs/TROUBLESHOOTING.md)** —
661
705
  install errors, `command not found`, every host timing out, slow scans,
662
706
  rejected input files
@@ -670,11 +714,11 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
670
714
  ## Contributing
671
715
 
672
716
  Contributions are welcome.
673
- [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/CONTRIBUTING.md)
717
+ [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/.github/CONTRIBUTING.md)
674
718
  covers setting up a development environment, running the tests, the commit
675
719
  message format that drives automated releases, and how the project website and
676
720
  IPMG Web demo are built. Everyone taking part is expected to follow the
677
- [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/CODE_OF_CONDUCT.md).
721
+ [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/.github/CODE_OF_CONDUCT.md).
678
722
 
679
723
  ---
680
724
 
@@ -343,6 +343,21 @@ ipmg diff --fail-on-change # exit 2 when anything changed (CI)
343
343
  ipmg history --limit 10 # list stored scans
344
344
  ```
345
345
 
346
+ To hear about changes without watching the terminal, send them to Slack,
347
+ Microsoft Teams, any JSON webhook, or email. Any `--notify-*` flag turns on
348
+ `--compare`, and with `--interval` every pass that changes something alerts:
349
+
350
+ ```bash
351
+ ipmg --input targets.txt --interval 15 --notify-slack # URL from $IPMG_NOTIFY_SLACK
352
+ ipmg --input targets.txt --notify-email ops@example.com --smtp-host smtp.example.com
353
+ ipmg diff --notify-webhook https://ops.example.com/ipmg --notify-severity critical
354
+ ```
355
+
356
+ Only changes at or above `--notify-severity` (default `warning`) trigger a
357
+ notification. A notification that fails is reported but never fails the scan.
358
+ Secrets such as webhook URLs and `IPMG_SMTP_PASSWORD` can come from environment
359
+ variables; see [Notifications](docs/COMMANDS.md#notifications) for all of them.
360
+
346
361
  What counts as a change:
347
362
 
348
363
  | Change | Severity | Meaning |
@@ -375,6 +390,11 @@ source**, so file-based and `--discover` runs do not get mixed up. Pass
375
390
  | `--latency-threshold` | `5` | Minimum latency delta in ms |
376
391
  | `--latency-pct` | `25` | Minimum relative latency change |
377
392
  | `--fail-on-change` | off | `ipmg diff` exits 2 when changes are found |
393
+ | `--notify-webhook` | off | POST the change report as JSON to a URL |
394
+ | `--notify-slack` | off | Post changes to a Slack incoming webhook |
395
+ | `--notify-teams` | off | Post changes to a Microsoft Teams Workflows webhook |
396
+ | `--notify-email` | off | Email the Markdown change report (with `--smtp-host`, `--smtp-port`, `--smtp-security`, `--smtp-user`, `--smtp-from`) |
397
+ | `--notify-severity` | `warning` | Only notify for changes at least this severe |
378
398
 
379
399
  ---
380
400
 
@@ -415,6 +435,14 @@ package, nothing is loaded from a CDN. It gives you:
415
435
  | `--no-browser` | off | Don't open the browser automatically |
416
436
  | `--db` | `~/.ipmg/dashboard.db` | History database location |
417
437
 
438
+ ### Scripting IPMG Web
439
+
440
+ Everything the dashboard does goes through a documented, versioned REST API
441
+ under `/api/v1`, so you can start scans, fetch results, and compare scans from
442
+ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)
443
+ covers authentication, errors, and worked `curl` examples; the running server
444
+ also serves interactive docs at `http://127.0.0.1:8080/docs`.
445
+
418
446
  ### On a server with no browser
419
447
 
420
448
  On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
@@ -570,12 +598,21 @@ them — so piping IPMG into a file or a log gives you clean text.
570
598
  | `--stream-refresh` | `0.25` | Seconds between progress-bar redraws while streaming (0.05-5) |
571
599
  | `--verbose` | off | Debug logging |
572
600
 
601
+ **Exit status**
602
+
603
+ | Flag | Default | Description |
604
+ | --- | --- | --- |
605
+ | `--fail-on-down` | off | Exit 3 if any target is not `Active` (reports are still written) |
606
+ | `--min-active` | off | Exit 3 if fewer than this percentage of targets are `Active` |
607
+
573
608
  **History and changes** — see [Change detection](#change-detection) for
574
609
  `--compare`, `--no-history`, `--db`, `--diff-formats`, `--diff-output`,
575
- `--latency-threshold`, `--latency-pct`, and `--fail-on-change`.
610
+ `--latency-threshold`, `--latency-pct`, `--fail-on-change`, and the
611
+ `--notify-*` flags.
576
612
 
577
613
  Exit codes: `0` success, `1` error, `2` changes detected
578
- (`ipmg diff --fail-on-change`), `130` interrupted.
614
+ (`ipmg diff --fail-on-change`), `3` hosts down (`--fail-on-down`,
615
+ `--min-active`), `130` interrupted.
579
616
 
580
617
  ---
581
618
 
@@ -596,12 +633,15 @@ itself is hardened accordingly:
596
633
  - Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
597
634
  so a bad input file cannot exhaust memory
598
635
  - All database access uses parameterized SQL
636
+ - The only outbound requests IPMG makes are the notifications you ask for
637
+ with a `--notify-*` flag. Their URLs and the SMTP password can come from
638
+ environment variables, and error messages never repeat them
599
639
 
600
640
  If you bind to a non-local address with `--host`, the token still guards the
601
641
  API. It travels over plain HTTP, though, so use an SSH tunnel or a reverse
602
642
  proxy with TLS on any network you don't trust.
603
643
 
604
- Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
644
+ Found a vulnerability? See [SECURITY.md](.github/SECURITY.md) for how to report it.
605
645
 
606
646
  ---
607
647
 
@@ -610,6 +650,9 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
610
650
  - **[Command reference](https://github.com/sameeralam3127/ipmg/blob/main/docs/COMMANDS.md)** —
611
651
  every command and flag with copy-paste examples, a safe session that tries
612
652
  everything on your own machine, exit codes, and error messages
653
+ - **[Web API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)** —
654
+ script IPMG Web over HTTP: start scans, fetch results, compare scans,
655
+ and follow live events
613
656
  - **[Troubleshooting](https://github.com/sameeralam3127/ipmg/blob/main/docs/TROUBLESHOOTING.md)** —
614
657
  install errors, `command not found`, every host timing out, slow scans,
615
658
  rejected input files
@@ -623,11 +666,11 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
623
666
  ## Contributing
624
667
 
625
668
  Contributions are welcome.
626
- [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/CONTRIBUTING.md)
669
+ [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/.github/CONTRIBUTING.md)
627
670
  covers setting up a development environment, running the tests, the commit
628
671
  message format that drives automated releases, and how the project website and
629
672
  IPMG Web demo are built. Everyone taking part is expected to follow the
630
- [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/CODE_OF_CONDUCT.md).
673
+ [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/.github/CODE_OF_CONDUCT.md).
631
674
 
632
675
  ---
633
676
 
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "ipmg"
16
- version = "2.2.0" # Managed automatically by semantic-release
16
+ version = "2.3.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"
@@ -53,6 +53,7 @@ dependencies = [
53
53
  "openpyxl>=3.1",
54
54
  "rich>=13.0",
55
55
  "fastapi>=0.110",
56
+ "pydantic>=2.7",
56
57
  "uvicorn>=0.27",
57
58
  "websockets>=12",
58
59
  "python-multipart>=0.0.9",
@@ -109,6 +110,9 @@ commit_message = "chore(release): {version}"
109
110
  build_command = "python -m build"
110
111
  major_on_zero = false
111
112
 
113
+ [tool.semantic_release.changelog.default_templates]
114
+ changelog_file = ".github/CHANGELOG.md"
115
+
112
116
 
113
117
  # ==========================================================
114
118
  # Setuptools (src layout)
@@ -2,4 +2,4 @@
2
2
  ipmg - IP Management & Ping Monitoring Tool
3
3
  """
4
4
 
5
- __version__ = "2.2.0"
5
+ __version__ = "2.3.0"
@@ -13,8 +13,10 @@ from ipmg.cli.parser import (
13
13
  build_web_parser,
14
14
  )
15
15
  from ipmg.core.diff import DiffOptions
16
+ from ipmg.core.health import HostsDownError
16
17
  from ipmg.core.security import print_disclaimer_once
17
18
  from ipmg.exceptions import IPMGError
19
+ from ipmg.infrastructure.notify import notify_options, send_notifications
18
20
  from ipmg.reporting import ui
19
21
  from ipmg.reporting.diff_report import export_diff, print_diff
20
22
  from ipmg.reporting.summary import print_scan_history
@@ -25,6 +27,7 @@ from ipmg.utils.helpers import configure_logging
25
27
  EXIT_OK = 0
26
28
  EXIT_ERROR = 1
27
29
  EXIT_CHANGES_DETECTED = 2
30
+ EXIT_HOSTS_DOWN = 3
28
31
  EXIT_INTERRUPTED = 130
29
32
 
30
33
  log = logging.getLogger(__name__)
@@ -77,6 +80,7 @@ def _diff_command(argv: List[str]) -> int:
77
80
  ui.error("Provide at most two scan ids: BASELINE TARGET.")
78
81
  return EXIT_ERROR
79
82
 
83
+ notify = notify_options(args)
80
84
  history = HistoryService.open(args.db)
81
85
  options = DiffOptions(
82
86
  latency_abs_ms=max(args.latency_threshold, 0.0),
@@ -93,6 +97,7 @@ def _diff_command(argv: List[str]) -> int:
93
97
  print_diff(diff, limit=args.limit)
94
98
  if args.diff_formats:
95
99
  export_diff(diff, args.diff_output, args.diff_formats)
100
+ send_notifications(diff, notify)
96
101
 
97
102
  if args.fail_on_change and diff.has_changes:
98
103
  return EXIT_CHANGES_DETECTED
@@ -134,6 +139,12 @@ def run(argv: Optional[List[str]] = None) -> int:
134
139
 
135
140
  try:
136
141
  return handler(handler_argv)
142
+ except HostsDownError as exc:
143
+ # Not an error: the scan finished and wrote its reports, but failed
144
+ # the check it was asked to make.
145
+ ui.blank()
146
+ ui.warn(str(exc))
147
+ return EXIT_HOSTS_DOWN
137
148
  except IPMGError as exc:
138
149
  ui.blank()
139
150
  ui.error(str(exc))
@@ -13,6 +13,16 @@ from ipmg.infrastructure.incremental import (
13
13
  MIN_AUTOSAVE_S,
14
14
  REPORT_FORMATS,
15
15
  )
16
+ from ipmg.infrastructure.notify import (
17
+ DEFAULT_NOTIFY_SEVERITY,
18
+ ENV_EMAIL,
19
+ ENV_SLACK,
20
+ ENV_SMTP_PASSWORD,
21
+ ENV_TEAMS,
22
+ ENV_WEBHOOK,
23
+ NOTIFY_SEVERITIES,
24
+ SMTP_SECURITY,
25
+ )
16
26
  from ipmg.reporting.diff_report import DIFF_FORMATS
17
27
  from ipmg.reporting.live import DEFAULT_REFRESH_S, MAX_REFRESH_S, MIN_REFRESH_S
18
28
 
@@ -43,6 +53,16 @@ def _port_list(value: str) -> tuple:
43
53
  raise argparse.ArgumentTypeError(str(exc)) from exc
44
54
 
45
55
 
56
+ def _percent(value: str) -> float:
57
+ try:
58
+ percent = float(value)
59
+ except ValueError:
60
+ raise argparse.ArgumentTypeError(f"invalid percentage: {value!r}") from None
61
+ if not 0 <= percent <= 100:
62
+ raise argparse.ArgumentTypeError(f"percentage out of range (0-100): {value}")
63
+ return percent
64
+
65
+
46
66
  def _add_database_argument(parser) -> None:
47
67
  parser.add_argument(
48
68
  "--db",
@@ -87,6 +107,84 @@ def _add_diff_export_arguments(parser) -> None:
87
107
  )
88
108
 
89
109
 
110
+ def _add_notify_arguments(parser: argparse.ArgumentParser) -> None:
111
+ group = parser.add_argument_group(
112
+ "change notifications",
113
+ "Send the change report when a comparison finds changes. Each URL flag may be "
114
+ "given without a value to read it from its environment variable, which keeps "
115
+ "webhook secrets out of the process list and your crontab.",
116
+ )
117
+ group.add_argument(
118
+ "--notify-webhook",
119
+ nargs="?",
120
+ const="",
121
+ default=None,
122
+ metavar="URL",
123
+ help=f"POST the change report as JSON to URL (or ${ENV_WEBHOOK}).",
124
+ )
125
+ group.add_argument(
126
+ "--notify-slack",
127
+ nargs="?",
128
+ const="",
129
+ default=None,
130
+ metavar="URL",
131
+ help=f"Post the changes to a Slack incoming webhook (or ${ENV_SLACK}).",
132
+ )
133
+ group.add_argument(
134
+ "--notify-teams",
135
+ nargs="?",
136
+ const="",
137
+ default=None,
138
+ metavar="URL",
139
+ help=f"Post the changes to a Microsoft Teams Workflows webhook (or ${ENV_TEAMS}).",
140
+ )
141
+ group.add_argument(
142
+ "--notify-email",
143
+ nargs="*",
144
+ default=None,
145
+ metavar="ADDRESS",
146
+ help=(
147
+ "Email the Markdown change report to these addresses "
148
+ f"(or the comma-separated ${ENV_EMAIL}). Needs --smtp-host."
149
+ ),
150
+ )
151
+ group.add_argument(
152
+ "--notify-severity",
153
+ choices=NOTIFY_SEVERITIES,
154
+ default=DEFAULT_NOTIFY_SEVERITY,
155
+ help=(
156
+ "Only notify when a change is at least this severe "
157
+ f"(default: {DEFAULT_NOTIFY_SEVERITY})."
158
+ ),
159
+ )
160
+ group.add_argument(
161
+ "--smtp-host",
162
+ metavar="HOST",
163
+ help="Mail server for --notify-email (or $IPMG_SMTP_HOST).",
164
+ )
165
+ group.add_argument(
166
+ "--smtp-port",
167
+ type=int,
168
+ metavar="PORT",
169
+ help="Mail server port (or $IPMG_SMTP_PORT; default: 587, 465 with ssl, 25 with none).",
170
+ )
171
+ group.add_argument(
172
+ "--smtp-security",
173
+ choices=SMTP_SECURITY,
174
+ help="How to encrypt the mail connection (or $IPMG_SMTP_SECURITY; default: starttls).",
175
+ )
176
+ group.add_argument(
177
+ "--smtp-user",
178
+ metavar="USER",
179
+ help=f"Mail server login (or $IPMG_SMTP_USER). The password is read from ${ENV_SMTP_PASSWORD}.",
180
+ )
181
+ group.add_argument(
182
+ "--smtp-from",
183
+ metavar="ADDRESS",
184
+ help="Sender address (or $IPMG_SMTP_FROM; default: the login, else ipmg@<this host>).",
185
+ )
186
+
187
+
90
188
  def build_parser() -> argparse.ArgumentParser:
91
189
  parser = ScanParser(
92
190
  prog="ipmg",
@@ -178,6 +276,20 @@ def build_parser() -> argparse.ArgumentParser:
178
276
  help="Start IPMG Web, the local browser UI (same as 'ipmg web').",
179
277
  )
180
278
 
279
+ checks = parser.add_argument_group("exit status (for cron and monitoring)")
280
+ checks.add_argument(
281
+ "--fail-on-down",
282
+ action="store_true",
283
+ help="Exit with status 3 if any target is not Active. Reports are still written.",
284
+ )
285
+ checks.add_argument(
286
+ "--min-active",
287
+ type=_percent,
288
+ default=None,
289
+ metavar="PERCENT",
290
+ help="Exit with status 3 if fewer than PERCENT of the targets are Active (0-100).",
291
+ )
292
+
181
293
  ports_group = parser.add_argument_group("TCP service discovery")
182
294
  ports_group.add_argument(
183
295
  "--scan-ports",
@@ -264,7 +376,10 @@ def build_parser() -> argparse.ArgumentParser:
264
376
  history.add_argument(
265
377
  "--compare",
266
378
  action="store_true",
267
- help="Compare this scan against the previous one and print a change report.",
379
+ help=(
380
+ "Compare this scan against the previous one and print a change report "
381
+ "(implied by any --notify-* flag)."
382
+ ),
268
383
  )
269
384
  history.add_argument(
270
385
  "--compare-any-source",
@@ -274,6 +389,7 @@ def build_parser() -> argparse.ArgumentParser:
274
389
  _add_database_argument(history)
275
390
  _add_diff_export_arguments(history)
276
391
  _add_diff_threshold_arguments(parser)
392
+ _add_notify_arguments(parser)
277
393
  parser.set_defaults(history=True)
278
394
  return parser
279
395
 
@@ -363,5 +479,6 @@ def build_diff_parser() -> argparse.ArgumentParser:
363
479
  _add_database_argument(parser)
364
480
  _add_diff_export_arguments(parser)
365
481
  _add_diff_threshold_arguments(parser)
482
+ _add_notify_arguments(parser)
366
483
  parser.add_argument("--verbose", action="store_true")
367
484
  return parser
@@ -48,6 +48,12 @@ _SEVERITY_BY_TYPE: Dict[ChangeType, Severity] = {
48
48
 
49
49
  _SEVERITY_RANK = {Severity.CRITICAL: 0, Severity.WARNING: 1, Severity.INFO: 2}
50
50
 
51
+
52
+ def meets_severity(severity: Severity, minimum: Severity) -> bool:
53
+ """Whether ``severity`` is at least as serious as ``minimum``."""
54
+ return _SEVERITY_RANK[severity] <= _SEVERITY_RANK[minimum]
55
+
56
+
51
57
  _TYPE_RANK = {change_type: index for index, change_type in enumerate(ChangeType)}
52
58
 
53
59
  CHANGE_LABELS: Dict[ChangeType, str] = {
@@ -0,0 +1,64 @@
1
+ """Pass/fail checks on a finished scan, for cron jobs and monitoring probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional, Sequence
7
+
8
+ from ipmg.core.diff import ip_sort_key
9
+ from ipmg.core.engine import HostResult
10
+ from ipmg.exceptions import IPMGError
11
+
12
+ ACTIVE_STATUS = "Active"
13
+
14
+ #: How many down hosts a failure message names before it summarises the rest.
15
+ MAX_NAMED_HOSTS = 5
16
+
17
+
18
+ class HostsDownError(IPMGError):
19
+ """Raised after a finished scan fails --fail-on-down or --min-active."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class HealthPolicy:
24
+ """When a scan counts as failed. The default policy never fails."""
25
+
26
+ #: Fail if any target is not ``Active``.
27
+ fail_on_down: bool = False
28
+ #: Fail if fewer than this percentage of targets are ``Active``.
29
+ min_active_pct: Optional[float] = None
30
+
31
+ @property
32
+ def enabled(self) -> bool:
33
+ return self.fail_on_down or self.min_active_pct is not None
34
+
35
+
36
+ def _name_hosts(ips: Sequence[str]) -> str:
37
+ # Results arrive in completion order; the message should read like the target list.
38
+ ips = sorted(ips, key=ip_sort_key)
39
+ named = ", ".join(ips[:MAX_NAMED_HOSTS])
40
+ hidden = len(ips) - MAX_NAMED_HOSTS
41
+ return f"{named} and {hidden} more" if hidden > 0 else named
42
+
43
+
44
+ def _format_pct(value: float) -> str:
45
+ return f"{value:.1f}".rstrip("0").rstrip(".") + "%"
46
+
47
+
48
+ def check_health(results: Sequence[HostResult], policy: HealthPolicy) -> Optional[str]:
49
+ """Why ``results`` fail ``policy``, or None when they pass."""
50
+ if not policy.enabled:
51
+ return None
52
+
53
+ total = len(results)
54
+ down = [result.ip for result in results if result.status != ACTIVE_STATUS]
55
+ active_pct = (total - len(down)) / total * 100 if total else 0.0
56
+
57
+ if policy.min_active_pct is not None and active_pct < policy.min_active_pct:
58
+ return (
59
+ f"Only {_format_pct(active_pct)} of {total} hosts are active, "
60
+ f"below --min-active {_format_pct(policy.min_active_pct)}."
61
+ )
62
+ if policy.fail_on_down and down:
63
+ return f"{len(down)} of {total} hosts are not active: {_name_hosts(down)}."
64
+ return None
@@ -27,3 +27,7 @@ class ReportError(IPMGError):
27
27
 
28
28
  class HistoryError(IPMGError):
29
29
  """Raised when scan history cannot be stored, read, or compared."""
30
+
31
+
32
+ class NotifyError(IPMGError):
33
+ """Raised when change notifications are configured incorrectly."""