f5audit 0.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.
Files changed (45) hide show
  1. f5audit-0.1.0/LICENSE +21 -0
  2. f5audit-0.1.0/MANIFEST.in +2 -0
  3. f5audit-0.1.0/PKG-INFO +184 -0
  4. f5audit-0.1.0/README.md +151 -0
  5. f5audit-0.1.0/f5audit/__init__.py +8 -0
  6. f5audit-0.1.0/f5audit/__main__.py +5 -0
  7. f5audit-0.1.0/f5audit/analyzer.py +301 -0
  8. f5audit-0.1.0/f5audit/cli.py +338 -0
  9. f5audit-0.1.0/f5audit/client.py +221 -0
  10. f5audit-0.1.0/f5audit/collector.py +304 -0
  11. f5audit-0.1.0/f5audit/correlator.py +108 -0
  12. f5audit-0.1.0/f5audit/models.py +104 -0
  13. f5audit-0.1.0/f5audit/parsing.py +385 -0
  14. f5audit-0.1.0/f5audit/report.py +407 -0
  15. f5audit-0.1.0/f5audit.egg-info/PKG-INFO +184 -0
  16. f5audit-0.1.0/f5audit.egg-info/SOURCES.txt +43 -0
  17. f5audit-0.1.0/f5audit.egg-info/dependency_links.txt +1 -0
  18. f5audit-0.1.0/f5audit.egg-info/entry_points.txt +2 -0
  19. f5audit-0.1.0/f5audit.egg-info/requires.txt +6 -0
  20. f5audit-0.1.0/f5audit.egg-info/top_level.txt +1 -0
  21. f5audit-0.1.0/pyproject.toml +60 -0
  22. f5audit-0.1.0/setup.cfg +4 -0
  23. f5audit-0.1.0/tests/__init__.py +0 -0
  24. f5audit-0.1.0/tests/conftest.py +63 -0
  25. f5audit-0.1.0/tests/fixtures/auth_partition.json +3 -0
  26. f5audit-0.1.0/tests/fixtures/cm_device.json +18 -0
  27. f5audit-0.1.0/tests/fixtures/monitors_http.json +5 -0
  28. f5audit-0.1.0/tests/fixtures/node_stats.json +24 -0
  29. f5audit-0.1.0/tests/fixtures/nodes.json +22 -0
  30. f5audit-0.1.0/tests/fixtures/pool_member_stats_pool-web.json +13 -0
  31. f5audit-0.1.0/tests/fixtures/pool_members_pool-web.json +12 -0
  32. f5audit-0.1.0/tests/fixtures/pools.json +32 -0
  33. f5audit-0.1.0/tests/fixtures/rules.json +9 -0
  34. f5audit-0.1.0/tests/fixtures/sys_failover_active.json +4 -0
  35. f5audit-0.1.0/tests/fixtures/sys_failover_standby.json +4 -0
  36. f5audit-0.1.0/tests/fixtures/sys_version.json +13 -0
  37. f5audit-0.1.0/tests/fixtures/virtual_stats.json +40 -0
  38. f5audit-0.1.0/tests/fixtures/virtual_stats_zero.json +37 -0
  39. f5audit-0.1.0/tests/fixtures/virtuals.json +30 -0
  40. f5audit-0.1.0/tests/test_analyzer.py +186 -0
  41. f5audit-0.1.0/tests/test_client.py +149 -0
  42. f5audit-0.1.0/tests/test_collector.py +137 -0
  43. f5audit-0.1.0/tests/test_correlator.py +71 -0
  44. f5audit-0.1.0/tests/test_parsing.py +158 -0
  45. f5audit-0.1.0/tests/test_report.py +124 -0
f5audit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Israel Fernandez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ # Ship a complete, runnable test suite in the sdist (fixtures included).
2
+ recursive-include tests *.py *.json
f5audit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: f5audit
3
+ Version: 0.1.0
4
+ Summary: Read-only audit tool for F5 BIG-IP LTM: finds unused nodes, pools, virtual servers and monitors via iControl REST and produces an Excel report.
5
+ Author-email: Israel Fernandez <israelfdzhdz@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/netcraftworks/f5audit
8
+ Project-URL: Repository, https://github.com/netcraftworks/f5audit
9
+ Project-URL: Issues, https://github.com/netcraftworks/f5audit/issues
10
+ Keywords: f5,big-ip,ltm,audit,icontrol-rest,network-automation
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: System :: Networking
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: requests>=2.25
28
+ Requires-Dist: openpyxl>=3.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=7; extra == "dev"
31
+ Requires-Dist: ruff>=0.6; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # f5audit
35
+
36
+ [![CI](https://github.com/netcraftworks/f5audit/actions/workflows/ci.yml/badge.svg)](https://github.com/netcraftworks/f5audit/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/f5audit)](https://pypi.org/project/f5audit/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/f5audit)](https://pypi.org/project/f5audit/)
39
+
40
+ Read-only audit tool for **F5 BIG-IP LTM**. It collects configuration and
41
+ statistics via iControl REST (GET only), correlates object references, and
42
+ produces a multi-sheet **Excel report** identifying unused objects (nodes,
43
+ pools, virtual servers, monitors) as input for a human-driven, change-
44
+ controlled cleanup.
45
+
46
+ **This tool never modifies the device.** The "suggested command" columns in
47
+ the report are informational text only; nothing is ever executed.
48
+
49
+ ## Safety design
50
+
51
+ - The HTTP client (`F5ReadOnlyClient`) exposes a single `get()` method.
52
+ There are no `post`/`patch`/`put`/`delete` methods. The only internal
53
+ write is the token login, hardcoded to `/mgmt/shared/authn/login`.
54
+ This is enforced by structural tests.
55
+ - No `tmsh`/bash execution endpoints are used or referenced anywhere.
56
+ - No explicit logout (deleting the token would be a write); tokens expire
57
+ on their own (~20 minutes).
58
+ - Passwords are read from an interactive prompt (`getpass`) or the
59
+ `F5_PASS` environment variable — never from a CLI argument, and never
60
+ written to disk or logs.
61
+ - Management-plane friendly: sequential requests only, `$top`/`$skip`
62
+ pagination, per-request delay (`--delay`, default 0.1 s), 30 s timeout,
63
+ max 2 retries with exponential backoff.
64
+
65
+ ## Requirements
66
+
67
+ - Python >= 3.9, `requests`, `openpyxl`
68
+ - An account with a **read-only role** (Auditor/Guest) and iControl REST
69
+ access on the BIG-IP
70
+ - Network access to the management interface (TCP 443)
71
+
72
+ ## Installation
73
+
74
+ ```
75
+ pip install f5audit
76
+ ```
77
+
78
+ From source: `pip install .` — or without installing, from the project
79
+ directory: `python -m f5audit ...`
80
+
81
+ ## Usage
82
+
83
+ ### 1. Validate access first
84
+
85
+ ```
86
+ f5audit validate --host 192.0.2.1 --user auditor --insecure
87
+ ```
88
+
89
+ Probes login plus the key GET endpoints and prints a diagnosis per
90
+ response code (bad credentials, missing REST access, denied endpoints,
91
+ old BIG-IP versions, network timeouts).
92
+
93
+ ### 2. Collect once, analyze offline N times
94
+
95
+ ```
96
+ f5audit collect --host 192.0.2.1 --user auditor --insecure --save-raw ./raw/
97
+ f5audit analyze --from-raw ./raw/ --out report.xlsx
98
+ ```
99
+
100
+ `collect` saves every raw JSON response (with timestamps) to disk;
101
+ `analyze --from-raw` re-analyzes from that cache **without touching the
102
+ F5 again**. This is the recommended workflow: one collection per session,
103
+ all further analysis offline.
104
+
105
+ ### One-step alternative
106
+
107
+ ```
108
+ f5audit analyze --host 192.0.2.1 --user auditor --insecure --save-raw ./raw/ --out report.xlsx
109
+ ```
110
+
111
+ ### Options
112
+
113
+ | Flag | Meaning |
114
+ |---|---|
115
+ | `--user` / `F5_USER` | Username (password via prompt or `F5_PASS`) |
116
+ | `--login-provider` | Token auth provider (default `tmos`; set for TACACS+/RADIUS) |
117
+ | `--insecure` | Skip TLS verification (self-signed mgmt certs); prints a warning |
118
+ | `--delay` | Seconds between requests (default 0.1) |
119
+ | `--top` | Pagination page size (default 100) |
120
+ | `--format xlsx\|csv` | Excel workbook or one CSV per sheet |
121
+ | `--allow-standby` | On a standby unit, emit traffic verdicts marked `UNRELIABLE (standby)` instead of skipping them |
122
+
123
+ Exit codes: `0` OK · `1` connection/auth error · `2` analysis completed
124
+ with warnings (standby device, denied partitions, missing endpoints).
125
+
126
+ ## Verdicts
127
+
128
+ | Verdict | Meaning |
129
+ |---|---|
130
+ | `ORPHAN` | Not referenced by anything (node: no pool membership; pool: no VS/iRule/policy reference; monitor: no user). Only issued when the inventory is complete and no dynamic iRules are active. |
131
+ | `MANUAL REVIEW` | A dynamic iRule (`pool $var`, `pool [...]`, datagroups) or a missing `ltm/rule` endpoint means the object *could* be referenced at runtime. Never auto-cleanup these. |
132
+ | `INACTIVE` | Configured and referenced, but disabled or zero total connections since the last counter reset. |
133
+ | `UNRELIABLE (standby)` | Traffic-based verdict computed on a standby unit (only with `--allow-standby`). |
134
+ | `UNRELIABLE (incomplete inventory)` | Some partitions were not readable; a reference could exist in an invisible partition. |
135
+ | `IN USE` | Everything else. |
136
+
137
+ ## Operational warning
138
+
139
+ - **Collect on the ACTIVE unit** of the HA pair. On a standby unit traffic
140
+ counters are zeros and the tool will skip traffic analysis (or mark it
141
+ `UNRELIABLE` with `--allow-standby`).
142
+ - Traffic counters reset on reboot / stats reset. Ideally collect after
143
+ **several weeks of uptime**; the report includes the failover-state age
144
+ as context.
145
+ - `INACTIVE` means "no traffic since the counters started", not "safe to
146
+ delete". Use the planned `compare` workflow (v2) — two collections some
147
+ weeks apart — to distinguish real zero traffic from a recent reset.
148
+ The raw cache already stores per-file timestamps to enable this.
149
+
150
+ ## Report sheets
151
+
152
+ 1. **Summary** — hostname, version, HA state, uptime context, partitions,
153
+ verdict counts, active warnings.
154
+ 2. **Inventory** — one row per pool member (plus rows for pool-less
155
+ nodes), fully correlated: node ↔ pool ↔ virtual server ↔ monitor ↔
156
+ iRule/policy references, statuses, traffic and verdict.
157
+ 3. **Orphan Nodes** · 4. **Orphan-Inactive Pools** · 5. **Inactive
158
+ Virtual Servers** · 6. **Orphan Monitors** — filtered views with
159
+ informational `tmsh` commands for the change request.
160
+ 7. **Manual Review** — objects touched by dynamic logic, with the
161
+ iRule/policy that causes the doubt.
162
+
163
+ Color coding: red = ORPHAN · yellow = MANUAL REVIEW / UNRELIABLE ·
164
+ orange = INACTIVE · green = IN USE.
165
+
166
+ ## Development
167
+
168
+ ```
169
+ pip install -e ".[dev]"
170
+ ruff check f5audit tests && ruff format --check f5audit tests
171
+ pytest
172
+ ```
173
+
174
+ No test touches the network; everything runs from anonymized JSON
175
+ fixtures and mocked HTTP sessions. Structural tests assert the client
176
+ exposes no write verbs and that no forbidden endpoint appears in the
177
+ source. CI runs lint plus the test suite on Python 3.9 through 3.14;
178
+ all checks must pass before a PR can merge.
179
+
180
+ Releases are published to PyPI automatically: bump `__version__` in
181
+ `f5audit/__init__.py`, merge, and create a GitHub release tagged
182
+ `v<version>`. The release workflow verifies the tag matches the package
183
+ version, builds, and publishes via PyPI Trusted Publishing (no stored
184
+ API tokens).
@@ -0,0 +1,151 @@
1
+ # f5audit
2
+
3
+ [![CI](https://github.com/netcraftworks/f5audit/actions/workflows/ci.yml/badge.svg)](https://github.com/netcraftworks/f5audit/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/f5audit)](https://pypi.org/project/f5audit/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/f5audit)](https://pypi.org/project/f5audit/)
6
+
7
+ Read-only audit tool for **F5 BIG-IP LTM**. It collects configuration and
8
+ statistics via iControl REST (GET only), correlates object references, and
9
+ produces a multi-sheet **Excel report** identifying unused objects (nodes,
10
+ pools, virtual servers, monitors) as input for a human-driven, change-
11
+ controlled cleanup.
12
+
13
+ **This tool never modifies the device.** The "suggested command" columns in
14
+ the report are informational text only; nothing is ever executed.
15
+
16
+ ## Safety design
17
+
18
+ - The HTTP client (`F5ReadOnlyClient`) exposes a single `get()` method.
19
+ There are no `post`/`patch`/`put`/`delete` methods. The only internal
20
+ write is the token login, hardcoded to `/mgmt/shared/authn/login`.
21
+ This is enforced by structural tests.
22
+ - No `tmsh`/bash execution endpoints are used or referenced anywhere.
23
+ - No explicit logout (deleting the token would be a write); tokens expire
24
+ on their own (~20 minutes).
25
+ - Passwords are read from an interactive prompt (`getpass`) or the
26
+ `F5_PASS` environment variable — never from a CLI argument, and never
27
+ written to disk or logs.
28
+ - Management-plane friendly: sequential requests only, `$top`/`$skip`
29
+ pagination, per-request delay (`--delay`, default 0.1 s), 30 s timeout,
30
+ max 2 retries with exponential backoff.
31
+
32
+ ## Requirements
33
+
34
+ - Python >= 3.9, `requests`, `openpyxl`
35
+ - An account with a **read-only role** (Auditor/Guest) and iControl REST
36
+ access on the BIG-IP
37
+ - Network access to the management interface (TCP 443)
38
+
39
+ ## Installation
40
+
41
+ ```
42
+ pip install f5audit
43
+ ```
44
+
45
+ From source: `pip install .` — or without installing, from the project
46
+ directory: `python -m f5audit ...`
47
+
48
+ ## Usage
49
+
50
+ ### 1. Validate access first
51
+
52
+ ```
53
+ f5audit validate --host 192.0.2.1 --user auditor --insecure
54
+ ```
55
+
56
+ Probes login plus the key GET endpoints and prints a diagnosis per
57
+ response code (bad credentials, missing REST access, denied endpoints,
58
+ old BIG-IP versions, network timeouts).
59
+
60
+ ### 2. Collect once, analyze offline N times
61
+
62
+ ```
63
+ f5audit collect --host 192.0.2.1 --user auditor --insecure --save-raw ./raw/
64
+ f5audit analyze --from-raw ./raw/ --out report.xlsx
65
+ ```
66
+
67
+ `collect` saves every raw JSON response (with timestamps) to disk;
68
+ `analyze --from-raw` re-analyzes from that cache **without touching the
69
+ F5 again**. This is the recommended workflow: one collection per session,
70
+ all further analysis offline.
71
+
72
+ ### One-step alternative
73
+
74
+ ```
75
+ f5audit analyze --host 192.0.2.1 --user auditor --insecure --save-raw ./raw/ --out report.xlsx
76
+ ```
77
+
78
+ ### Options
79
+
80
+ | Flag | Meaning |
81
+ |---|---|
82
+ | `--user` / `F5_USER` | Username (password via prompt or `F5_PASS`) |
83
+ | `--login-provider` | Token auth provider (default `tmos`; set for TACACS+/RADIUS) |
84
+ | `--insecure` | Skip TLS verification (self-signed mgmt certs); prints a warning |
85
+ | `--delay` | Seconds between requests (default 0.1) |
86
+ | `--top` | Pagination page size (default 100) |
87
+ | `--format xlsx\|csv` | Excel workbook or one CSV per sheet |
88
+ | `--allow-standby` | On a standby unit, emit traffic verdicts marked `UNRELIABLE (standby)` instead of skipping them |
89
+
90
+ Exit codes: `0` OK · `1` connection/auth error · `2` analysis completed
91
+ with warnings (standby device, denied partitions, missing endpoints).
92
+
93
+ ## Verdicts
94
+
95
+ | Verdict | Meaning |
96
+ |---|---|
97
+ | `ORPHAN` | Not referenced by anything (node: no pool membership; pool: no VS/iRule/policy reference; monitor: no user). Only issued when the inventory is complete and no dynamic iRules are active. |
98
+ | `MANUAL REVIEW` | A dynamic iRule (`pool $var`, `pool [...]`, datagroups) or a missing `ltm/rule` endpoint means the object *could* be referenced at runtime. Never auto-cleanup these. |
99
+ | `INACTIVE` | Configured and referenced, but disabled or zero total connections since the last counter reset. |
100
+ | `UNRELIABLE (standby)` | Traffic-based verdict computed on a standby unit (only with `--allow-standby`). |
101
+ | `UNRELIABLE (incomplete inventory)` | Some partitions were not readable; a reference could exist in an invisible partition. |
102
+ | `IN USE` | Everything else. |
103
+
104
+ ## Operational warning
105
+
106
+ - **Collect on the ACTIVE unit** of the HA pair. On a standby unit traffic
107
+ counters are zeros and the tool will skip traffic analysis (or mark it
108
+ `UNRELIABLE` with `--allow-standby`).
109
+ - Traffic counters reset on reboot / stats reset. Ideally collect after
110
+ **several weeks of uptime**; the report includes the failover-state age
111
+ as context.
112
+ - `INACTIVE` means "no traffic since the counters started", not "safe to
113
+ delete". Use the planned `compare` workflow (v2) — two collections some
114
+ weeks apart — to distinguish real zero traffic from a recent reset.
115
+ The raw cache already stores per-file timestamps to enable this.
116
+
117
+ ## Report sheets
118
+
119
+ 1. **Summary** — hostname, version, HA state, uptime context, partitions,
120
+ verdict counts, active warnings.
121
+ 2. **Inventory** — one row per pool member (plus rows for pool-less
122
+ nodes), fully correlated: node ↔ pool ↔ virtual server ↔ monitor ↔
123
+ iRule/policy references, statuses, traffic and verdict.
124
+ 3. **Orphan Nodes** · 4. **Orphan-Inactive Pools** · 5. **Inactive
125
+ Virtual Servers** · 6. **Orphan Monitors** — filtered views with
126
+ informational `tmsh` commands for the change request.
127
+ 7. **Manual Review** — objects touched by dynamic logic, with the
128
+ iRule/policy that causes the doubt.
129
+
130
+ Color coding: red = ORPHAN · yellow = MANUAL REVIEW / UNRELIABLE ·
131
+ orange = INACTIVE · green = IN USE.
132
+
133
+ ## Development
134
+
135
+ ```
136
+ pip install -e ".[dev]"
137
+ ruff check f5audit tests && ruff format --check f5audit tests
138
+ pytest
139
+ ```
140
+
141
+ No test touches the network; everything runs from anonymized JSON
142
+ fixtures and mocked HTTP sessions. Structural tests assert the client
143
+ exposes no write verbs and that no forbidden endpoint appears in the
144
+ source. CI runs lint plus the test suite on Python 3.9 through 3.14;
145
+ all checks must pass before a PR can merge.
146
+
147
+ Releases are published to PyPI automatically: bump `__version__` in
148
+ `f5audit/__init__.py`, merge, and create a GitHub release tagged
149
+ `v<version>`. The release workflow verifies the tag matches the package
150
+ version, builds, and publishes via PyPI Trusted Publishing (no stored
151
+ API tokens).
@@ -0,0 +1,8 @@
1
+ """f5audit: read-only audit tool for F5 BIG-IP LTM configurations.
2
+
3
+ Collects configuration and statistics via iControl REST (GET only),
4
+ correlates object references, and reports orphaned / inactive objects
5
+ as input for a human-driven, change-controlled cleanup.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,301 @@
1
+ """Verdict rules.
2
+
3
+ Cross-cutting rules (spec section 8):
4
+ - No object touched by dynamic pool-selection logic can ever be ORPHAN;
5
+ the ceiling is MANUAL REVIEW.
6
+ - Traffic-based verdicts are only trusted when the device is ACTIVE;
7
+ on a standby unit they are either skipped (default) or emitted as
8
+ UNRELIABLE (standby) with --allow-standby.
9
+ - Incomplete inventory (denied partitions/endpoints) degrades orphan
10
+ verdicts, because a reference could live in an invisible partition.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from .correlator import Correlation, is_builtin_monitor
18
+ from .parsing import ParsedData
19
+
20
+
21
+ class Verdict:
22
+ ORPHAN = "ORPHAN"
23
+ MANUAL_REVIEW = "MANUAL REVIEW"
24
+ INACTIVE = "INACTIVE"
25
+ UNRELIABLE_STANDBY = "UNRELIABLE (standby)"
26
+ UNRELIABLE_INVENTORY = "UNRELIABLE (incomplete inventory)"
27
+ IN_USE = "IN USE"
28
+
29
+
30
+ @dataclass
31
+ class ObjectVerdict:
32
+ verdict: str
33
+ notes: str = ""
34
+
35
+
36
+ @dataclass
37
+ class ManualReviewItem:
38
+ object_type: str
39
+ full_path: str
40
+ reason: str
41
+ caused_by: str = ""
42
+
43
+
44
+ @dataclass
45
+ class AnalysisResult:
46
+ node_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
47
+ pool_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
48
+ virtual_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
49
+ monitor_verdicts: dict[str, ObjectVerdict] = field(default_factory=dict)
50
+ manual_review: list[ManualReviewItem] = field(default_factory=list)
51
+ warnings: list[str] = field(default_factory=list)
52
+ stats_analysis_skipped: bool = False
53
+
54
+ def verdict_counts(self) -> dict[str, int]:
55
+ counts: dict[str, int] = {}
56
+ for verdicts in (
57
+ self.node_verdicts,
58
+ self.pool_verdicts,
59
+ self.virtual_verdicts,
60
+ self.monitor_verdicts,
61
+ ):
62
+ for object_verdict in verdicts.values():
63
+ counts[object_verdict.verdict] = counts.get(object_verdict.verdict, 0) + 1
64
+ return counts
65
+
66
+
67
+ def _conns_zero(total_conns: int | None) -> bool:
68
+ return total_conns is not None and int(total_conns) == 0
69
+
70
+
71
+ class Analyzer:
72
+ def __init__(
73
+ self, parsed: ParsedData, correlation: Correlation, *, allow_standby: bool = False
74
+ ):
75
+ self.parsed = parsed
76
+ self.correlation = correlation
77
+ self.allow_standby = allow_standby
78
+
79
+ system = parsed.system
80
+ self.is_standby = system.failover_state == "standby"
81
+ self.inventory_complete = not system.partitions_denied and not any(
82
+ "/ltm/node" in ep or "/ltm/pool" in ep or "/ltm/virtual" in ep
83
+ for ep in system.missing_endpoints
84
+ )
85
+ self.irules_available = not any("/ltm/rule" in ep for ep in system.missing_endpoints)
86
+ # Uptime context: traffic counters reset on reboot / stats reset.
87
+ self.traffic_note = (
88
+ "Traffic counters reset on reboot/stats-reset; "
89
+ f"{system.uptime or 'device uptime unknown'}."
90
+ )
91
+
92
+ # ------------------------------------------------------------------
93
+
94
+ def run(self) -> AnalysisResult:
95
+ result = AnalysisResult()
96
+ self._collect_warnings(result)
97
+ self._analyze_nodes(result)
98
+ self._analyze_virtuals(result)
99
+ self._analyze_pools(result)
100
+ self._analyze_monitors(result)
101
+ self._analyze_irules(result)
102
+ return result
103
+
104
+ def _collect_warnings(self, result: AnalysisResult) -> None:
105
+ system = self.parsed.system
106
+ if self.is_standby:
107
+ if self.allow_standby:
108
+ result.warnings.append(
109
+ "Device is STANDBY: traffic statistics are not "
110
+ "representative. Traffic-based verdicts are marked "
111
+ "UNRELIABLE (standby). Re-run against the ACTIVE unit."
112
+ )
113
+ else:
114
+ result.stats_analysis_skipped = True
115
+ result.warnings.append(
116
+ "Device is STANDBY: traffic-based analysis was SKIPPED "
117
+ "(configuration-orphan analysis still ran). Re-run "
118
+ "against the ACTIVE unit, or use --allow-standby to "
119
+ "force traffic verdicts marked as UNRELIABLE."
120
+ )
121
+ if system.partitions_denied:
122
+ result.warnings.append(
123
+ "Access denied to partition(s): "
124
+ + ", ".join(system.partitions_denied)
125
+ + ". Orphan verdicts are degraded to UNRELIABLE (incomplete "
126
+ "inventory) because objects could be referenced from an "
127
+ "invisible partition."
128
+ )
129
+ if not self.irules_available:
130
+ result.warnings.append(
131
+ "ltm/rule was not readable: pool orphan verdicts are capped "
132
+ "at MANUAL REVIEW (an iRule could reference them)."
133
+ )
134
+ for endpoint in system.missing_endpoints:
135
+ if "/ltm/rule" not in endpoint:
136
+ result.warnings.append(f"Endpoint not collected: {endpoint}")
137
+
138
+ # ------------------------------------------------------------------
139
+
140
+ def _analyze_nodes(self, result: AnalysisResult) -> None:
141
+ for path, _node in self.parsed.nodes.items():
142
+ pools = self.correlation.node_to_pools.get(path)
143
+ if pools:
144
+ result.node_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
145
+ elif not self.inventory_complete:
146
+ result.node_verdicts[path] = ObjectVerdict(
147
+ Verdict.UNRELIABLE_INVENTORY,
148
+ "Not a member of any visible pool, but some partitions were not readable.",
149
+ )
150
+ else:
151
+ result.node_verdicts[path] = ObjectVerdict(
152
+ Verdict.ORPHAN, "Not a member of any pool."
153
+ )
154
+
155
+ def _analyze_virtuals(self, result: AnalysisResult) -> None:
156
+ for path, virtual in self.parsed.virtuals.items():
157
+ if virtual.admin_state == "disabled":
158
+ result.virtual_verdicts[path] = ObjectVerdict(
159
+ Verdict.INACTIVE, "Administratively disabled."
160
+ )
161
+ continue
162
+ if not virtual.default_pool and (virtual.irules or virtual.policies):
163
+ result.virtual_verdicts[path] = ObjectVerdict(
164
+ Verdict.MANUAL_REVIEW,
165
+ "No default pool; traffic is steered by iRules/policies.",
166
+ )
167
+ result.manual_review.append(
168
+ ManualReviewItem(
169
+ "virtual_server",
170
+ path,
171
+ "No default pool but has iRules/policies attached",
172
+ ", ".join(virtual.irules + virtual.policies),
173
+ )
174
+ )
175
+ continue
176
+ if _conns_zero(virtual.total_conns):
177
+ self._traffic_verdict(
178
+ result.virtual_verdicts,
179
+ path,
180
+ "Enabled but zero total connections. " + self.traffic_note,
181
+ )
182
+ continue
183
+ result.virtual_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
184
+
185
+ def _traffic_verdict(self, verdicts: dict[str, ObjectVerdict], path: str, note: str) -> None:
186
+ """Emit a traffic-based INACTIVE verdict, degraded on standby."""
187
+ if self.is_standby:
188
+ if self.allow_standby:
189
+ verdicts[path] = ObjectVerdict(
190
+ Verdict.UNRELIABLE_STANDBY,
191
+ "Zero traffic, but this device is standby. " + note,
192
+ )
193
+ else:
194
+ verdicts[path] = ObjectVerdict(
195
+ Verdict.IN_USE,
196
+ "Traffic analysis skipped (standby device).",
197
+ )
198
+ else:
199
+ verdicts[path] = ObjectVerdict(Verdict.INACTIVE, note)
200
+
201
+ def _analyze_pools(self, result: AnalysisResult) -> None:
202
+ for path, _pool in self.parsed.pools.items():
203
+ virtuals = self.correlation.pool_to_virtuals.get(path, set())
204
+ irule_refs = self.correlation.pool_to_irules.get(path, set())
205
+ policy_refs = self.correlation.pool_to_policies.get(path, set())
206
+
207
+ if not virtuals and not irule_refs and not policy_refs:
208
+ self._unreferenced_pool_verdict(result, path)
209
+ continue
210
+
211
+ # Referenced pool: inactive if every attached VS has zero traffic.
212
+ if (
213
+ virtuals
214
+ and all(
215
+ _conns_zero(self.parsed.virtuals[v].total_conns)
216
+ for v in virtuals
217
+ if v in self.parsed.virtuals
218
+ )
219
+ and any(v in self.parsed.virtuals for v in virtuals)
220
+ ):
221
+ self._traffic_verdict(
222
+ result.pool_verdicts,
223
+ path,
224
+ "All attached virtual servers have zero total "
225
+ "connections. " + self.traffic_note,
226
+ )
227
+ continue
228
+ result.pool_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
229
+
230
+ def _unreferenced_pool_verdict(self, result: AnalysisResult, path: str) -> None:
231
+ if not self.inventory_complete:
232
+ result.pool_verdicts[path] = ObjectVerdict(
233
+ Verdict.UNRELIABLE_INVENTORY,
234
+ "No visible references, but some partitions were not readable.",
235
+ )
236
+ elif not self.irules_available:
237
+ result.pool_verdicts[path] = ObjectVerdict(
238
+ Verdict.MANUAL_REVIEW,
239
+ "No VS/policy references; iRules could not be read "
240
+ "(ltm/rule denied), so an iRule reference cannot be ruled out.",
241
+ )
242
+ result.manual_review.append(
243
+ ManualReviewItem(
244
+ "pool",
245
+ path,
246
+ "Unreferenced, but ltm/rule was not readable",
247
+ )
248
+ )
249
+ elif self.correlation.has_attached_dynamic_irules:
250
+ dynamic = ", ".join(self.correlation.attached_dynamic_irules)
251
+ result.pool_verdicts[path] = ObjectVerdict(
252
+ Verdict.MANUAL_REVIEW,
253
+ "No static references, but dynamic pool-selection iRules "
254
+ f"are active ({dynamic}); the pool could be selected at "
255
+ "runtime.",
256
+ )
257
+ result.manual_review.append(
258
+ ManualReviewItem(
259
+ "pool",
260
+ path,
261
+ "No static references but dynamic iRules are active",
262
+ dynamic,
263
+ )
264
+ )
265
+ else:
266
+ result.pool_verdicts[path] = ObjectVerdict(
267
+ Verdict.ORPHAN,
268
+ "Not referenced by any virtual server, iRule or policy.",
269
+ )
270
+
271
+ def _analyze_monitors(self, result: AnalysisResult) -> None:
272
+ for path, _monitor in self.parsed.monitors.items():
273
+ if is_builtin_monitor(path):
274
+ result.monitor_verdicts[path] = ObjectVerdict(
275
+ Verdict.IN_USE, "F5 built-in monitor (excluded from orphan analysis)."
276
+ )
277
+ continue
278
+ users = self.correlation.monitor_users.get(path)
279
+ if users:
280
+ result.monitor_verdicts[path] = ObjectVerdict(Verdict.IN_USE)
281
+ elif not self.inventory_complete:
282
+ result.monitor_verdicts[path] = ObjectVerdict(
283
+ Verdict.UNRELIABLE_INVENTORY,
284
+ "Unused in visible partitions, but some partitions were not readable.",
285
+ )
286
+ else:
287
+ result.monitor_verdicts[path] = ObjectVerdict(
288
+ Verdict.ORPHAN, "Not used by any node or pool."
289
+ )
290
+
291
+ def _analyze_irules(self, result: AnalysisResult) -> None:
292
+ for path, irule in self.parsed.irules.items():
293
+ if irule.has_dynamic_pool_selection:
294
+ result.manual_review.append(
295
+ ManualReviewItem(
296
+ "irule",
297
+ path,
298
+ "Selects pools dynamically ($variable, [command] or "
299
+ "datagroup); static analysis cannot resolve its targets",
300
+ )
301
+ )