cn-tool 0.7.0__py3-none-any.whl
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.
- cn_tool/__init__.py +1 -0
- cn_tool/__main__.py +5 -0
- cn_tool/cn_buildstamp.py +104 -0
- cn_tool/core/__init__.py +0 -0
- cn_tool/core/background.py +130 -0
- cn_tool/core/base.py +210 -0
- cn_tool/core/event_bus.py +57 -0
- cn_tool/core/loader.py +164 -0
- cn_tool/main.py +1170 -0
- cn_tool/modules/__init__.py +0 -0
- cn_tool/modules/aruba_bssids.py +121 -0
- cn_tool/modules/bulk_ping.py +708 -0
- cn_tool/modules/bulk_resolve.py +242 -0
- cn_tool/modules/bulk_trace.py +156 -0
- cn_tool/modules/config_analyzer_module.py +254 -0
- cn_tool/modules/config_search.py +559 -0
- cn_tool/modules/delete_report.py +32 -0
- cn_tool/modules/device_query.py +265 -0
- cn_tool/modules/fqdn_request.py +464 -0
- cn_tool/modules/ip_request.py +569 -0
- cn_tool/modules/location_request.py +361 -0
- cn_tool/modules/manual_email.py +56 -0
- cn_tool/modules/setup.py +695 -0
- cn_tool/modules/subnet_request.py +1411 -0
- cn_tool/plugins/__init__.py +0 -0
- cn_tool/plugins/activedirectory_support.py +252 -0
- cn_tool/plugins/config_analyzer_settings.py +46 -0
- cn_tool/plugins/email_support.py +74 -0
- cn_tool/plugins/sdwan_yaml_search.py +408 -0
- cn_tool/sample.cn +122 -0
- cn_tool/utils/__init__.py +0 -0
- cn_tool/utils/ad_helper.py +196 -0
- cn_tool/utils/api.py +928 -0
- cn_tool/utils/app_lifecycle.py +94 -0
- cn_tool/utils/auth.py +540 -0
- cn_tool/utils/cache.py +110 -0
- cn_tool/utils/cache_helpers.py +1569 -0
- cn_tool/utils/cache_optimized.py +244 -0
- cn_tool/utils/cache_status.py +174 -0
- cn_tool/utils/cli_input.py +47 -0
- cn_tool/utils/color_schemes.py +164 -0
- cn_tool/utils/config.py +718 -0
- cn_tool/utils/config_history.py +218 -0
- cn_tool/utils/decorators.py +26 -0
- cn_tool/utils/dhcp_options.py +156 -0
- cn_tool/utils/diagnostics.py +133 -0
- cn_tool/utils/display.py +469 -0
- cn_tool/utils/email_helper.py +172 -0
- cn_tool/utils/file_io.py +731 -0
- cn_tool/utils/gpg.py +111 -0
- cn_tool/utils/hash.py +23 -0
- cn_tool/utils/infoblox_inheritance.py +202 -0
- cn_tool/utils/infoblox_safety.py +51 -0
- cn_tool/utils/infoblox_ux.py +14 -0
- cn_tool/utils/logging.py +88 -0
- cn_tool/utils/network_views.py +328 -0
- cn_tool/utils/oscompat.py +103 -0
- cn_tool/utils/parsers.py +639 -0
- cn_tool/utils/process_data.py +447 -0
- cn_tool/utils/render.py +123 -0
- cn_tool/utils/search_helpers.py +94 -0
- cn_tool/utils/ssh.py +98 -0
- cn_tool/utils/stats.py +1146 -0
- cn_tool/utils/user_input.py +411 -0
- cn_tool/utils/validation.py +241 -0
- cn_tool/version +4 -0
- cn_tool/wordlists/keywords.py +1200 -0
- cn_tool-0.7.0.dist-info/METADATA +951 -0
- cn_tool-0.7.0.dist-info/RECORD +89 -0
- cn_tool-0.7.0.dist-info/WHEEL +4 -0
- cn_tool-0.7.0.dist-info/entry_points.txt +3 -0
- cn_tool-0.7.0.dist-info/licenses/LICENSE +201 -0
- config_analyzer/__init__.py +4 -0
- config_analyzer/__main__.py +5 -0
- config_analyzer/app.py +129 -0
- config_analyzer/cli.py +109 -0
- config_analyzer/debug.py +97 -0
- config_analyzer/differ.py +134 -0
- config_analyzer/filter_mixin.py +36 -0
- config_analyzer/formatting.py +13 -0
- config_analyzer/keymap.py +94 -0
- config_analyzer/parser.py +196 -0
- config_analyzer/repo_browser.py +1455 -0
- config_analyzer/search.py +207 -0
- config_analyzer/tips.py +74 -0
- config_analyzer/tui.py +1000 -0
- config_analyzer/utils.py +135 -0
- config_analyzer/version.py +2 -0
- config_analyzer/widgets.py +360 -0
cn_tool/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""cn-tool: Infoblox lookups and network checks."""
|
cn_tool/__main__.py
ADDED
cn_tool/cn_buildstamp.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Shared build-stamp reader for cn-tool and its bundled CLIs.
|
|
2
|
+
|
|
3
|
+
The ``cn_tool/version`` file (next to this module) is the single CI-managed source of truth for the
|
|
4
|
+
*platform* version (``MAJOR.MINOR.BUILD``) plus the short git ``BUILD_HASH``.
|
|
5
|
+
It is rewritten on every release by ``.github/workflows/release-public.yml``.
|
|
6
|
+
|
|
7
|
+
Each bundled tool additionally carries its own
|
|
8
|
+
*component* ``__version__`` and renders a combined ``--version`` line through
|
|
9
|
+
this module, so the per-tool version and the shared package build stamp stay
|
|
10
|
+
consistent and the parsing logic lives in exactly one place.
|
|
11
|
+
|
|
12
|
+
Hybrid ``--version`` format::
|
|
13
|
+
|
|
14
|
+
cli-tool 0.2.1 (cn-tool 0.2.88, build 1b5d97f)
|
|
15
|
+
^^^^^ ^^^^^^ ^^^^^^^
|
|
16
|
+
component package version git hash
|
|
17
|
+
(per tool) (version file) (version file)
|
|
18
|
+
|
|
19
|
+
When the ``version`` file is missing or unparseable, the platform suffix is
|
|
20
|
+
omitted and the component version is shown alone (never a misleading
|
|
21
|
+
``unknown``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import NamedTuple, Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BuildStamp(NamedTuple):
|
|
32
|
+
package_version: str # e.g. "0.2.88"
|
|
33
|
+
build_hash: str # e.g. "1b5d97f" (may be "")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_version_text(content: str) -> Optional[BuildStamp]:
|
|
37
|
+
"""Parse the ``KEY=value`` body of a ``version`` file.
|
|
38
|
+
|
|
39
|
+
Returns ``None`` when the required ``MAJOR``/``MINOR`` keys are missing
|
|
40
|
+
or non-numeric (a hand-edited file must not yield a stamp like
|
|
41
|
+
``"abc.2.88"``), so callers can fall back to a component-only version
|
|
42
|
+
line. A non-numeric optional ``BUILD`` degrades to ``MAJOR.MINOR``.
|
|
43
|
+
"""
|
|
44
|
+
parts: dict[str, str] = {}
|
|
45
|
+
for line in content.splitlines():
|
|
46
|
+
key, sep, val = line.partition("=")
|
|
47
|
+
if sep:
|
|
48
|
+
parts[key.strip()] = val.strip()
|
|
49
|
+
|
|
50
|
+
major = parts.get("MAJOR", "")
|
|
51
|
+
minor = parts.get("MINOR", "")
|
|
52
|
+
build = parts.get("BUILD", "")
|
|
53
|
+
if not (major.isdigit() and minor.isdigit()):
|
|
54
|
+
return None
|
|
55
|
+
if not build.isdigit():
|
|
56
|
+
build = ""
|
|
57
|
+
build_hash = parts.get("BUILD_HASH", "")
|
|
58
|
+
|
|
59
|
+
package_version = f"{major}.{minor}.{build}" if build else f"{major}.{minor}"
|
|
60
|
+
return BuildStamp(package_version=package_version, build_hash=build_hash)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@lru_cache(maxsize=1)
|
|
64
|
+
def build_stamp() -> Optional[BuildStamp]:
|
|
65
|
+
"""Parse the ``version`` file next to this module (cn_tool/version).
|
|
66
|
+
|
|
67
|
+
Returns ``None`` when the file is absent or unparseable so callers can fall
|
|
68
|
+
back to a component-only version line. Result is cached after first call.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
version_file = Path(__file__).resolve().parent / "version"
|
|
72
|
+
content = version_file.read_text(encoding="utf-8")
|
|
73
|
+
except OSError:
|
|
74
|
+
return None
|
|
75
|
+
return parse_version_text(content)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def package_version_string() -> str:
|
|
79
|
+
"""Legacy combined string used by cn-tool: ``"0.2.88 hash 1b5d97f"``.
|
|
80
|
+
|
|
81
|
+
Falls back to ``"unknown"`` when no build stamp is available. This matches
|
|
82
|
+
the string the CI previously injected into ``main.py`` at build time.
|
|
83
|
+
"""
|
|
84
|
+
stamp = build_stamp()
|
|
85
|
+
if stamp is None:
|
|
86
|
+
return "unknown"
|
|
87
|
+
if stamp.build_hash:
|
|
88
|
+
return f"{stamp.package_version} hash {stamp.build_hash}"
|
|
89
|
+
return stamp.package_version
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def version_line(tool_name: str, component_version: str) -> str:
|
|
93
|
+
"""Render a bundled tool's ``--version`` line in the hybrid format.
|
|
94
|
+
|
|
95
|
+
Example: ``cli-tool 0.2.1 (cn-tool 0.2.88, build 1b5d97f)``.
|
|
96
|
+
Without a build stamp the platform suffix is dropped: ``cli-tool 0.2.1``.
|
|
97
|
+
"""
|
|
98
|
+
stamp = build_stamp()
|
|
99
|
+
if stamp is None:
|
|
100
|
+
return f"{tool_name} {component_version}"
|
|
101
|
+
suffix = f"cn-tool {stamp.package_version}"
|
|
102
|
+
if stamp.build_hash:
|
|
103
|
+
suffix += f", build {stamp.build_hash}"
|
|
104
|
+
return f"{tool_name} {component_version} ({suffix})"
|
cn_tool/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from time import time, sleep
|
|
3
|
+
from cn_tool.core.base import ScriptContext
|
|
4
|
+
from cn_tool.utils.cache import CacheManager
|
|
5
|
+
from cn_tool.utils.cache_helpers import mt_index_configurations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def start_background_tasks(ctx: ScriptContext) -> None:
|
|
9
|
+
"""
|
|
10
|
+
Initializes and starts all non-blocking background processes for the application.
|
|
11
|
+
"""
|
|
12
|
+
if ctx.cfg.get("cache_enabled", False):
|
|
13
|
+
# Initialize the CacheManager instance and attach it to the context
|
|
14
|
+
ctx.cache = CacheManager.get_instance(
|
|
15
|
+
directory=ctx.cfg["cache_directory"],
|
|
16
|
+
logger=ctx.logger,
|
|
17
|
+
cfg=ctx.cfg,
|
|
18
|
+
)
|
|
19
|
+
# Start the cache indexing in a separate thread
|
|
20
|
+
threading.Thread(target=_background_cache_init, args=[ctx], daemon=True).start()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _background_cache_init(ctx: ScriptContext) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Checks the cache state and triggers a re-index if necessary.
|
|
26
|
+
This function is designed to be run in a background thread.
|
|
27
|
+
(Original `background_cache_init` logic)
|
|
28
|
+
"""
|
|
29
|
+
logger = ctx.logger
|
|
30
|
+
if not ctx.cache:
|
|
31
|
+
logger.info("Index Cache - Cache does not exist, skipping.")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
cache: CacheManager = ctx.cache
|
|
35
|
+
|
|
36
|
+
if cache.dc.get("indexing"):
|
|
37
|
+
logger.info("Index Cache - Another process is already indexing; will watch and recheck later.")
|
|
38
|
+
threading.Thread(target=_wait_for_indexing_and_recheck, args=[ctx], daemon=True).start()
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
updated_time = cache.dc.get("updated", 0)
|
|
42
|
+
if not isinstance(updated_time, (int, float)):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Check if the cache was updated recently and if versions match
|
|
46
|
+
if (int(time()) - int(updated_time)) <= 30 and cache.dc.get("version") == ctx.cfg.get("cache_version", None):
|
|
47
|
+
logger.info("Index Cache - State is up-to-date, skipping checks.")
|
|
48
|
+
cache.log_stats("startup-check")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if cache.dc.get("version") != ctx.cfg["cache_version"]:
|
|
52
|
+
logger.info(f"Index Cache - New cache version {ctx.cfg.get('cache_version', 'Unspecified')} in config.")
|
|
53
|
+
# Mark resetting so UI doesn't briefly show Ready/0 devices
|
|
54
|
+
try:
|
|
55
|
+
cache.dc.set("indexing", True)
|
|
56
|
+
cache.dc.set("indexing_phase", "resetting")
|
|
57
|
+
cache.dc.set("indexing_started", int(time()))
|
|
58
|
+
cache.dc.set("indexing_last_update", int(time()))
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
cache.reset_cache()
|
|
62
|
+
|
|
63
|
+
logger.info("Index Cache - Starting cache check and potential re-indexing.")
|
|
64
|
+
try:
|
|
65
|
+
mt_index_configurations(ctx)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Index Cache - Error during background initialization: {e}", exc_info=True)
|
|
68
|
+
# Ensure the indexing flag is cleared on error
|
|
69
|
+
cache.dc.pop("indexing", None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _recheck_and_index_if_needed(ctx: ScriptContext) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Re-run the same readiness check and trigger indexing if needed.
|
|
75
|
+
Used when we started while another process was indexing.
|
|
76
|
+
"""
|
|
77
|
+
logger = ctx.logger
|
|
78
|
+
cache: CacheManager = ctx.cache # type: ignore
|
|
79
|
+
|
|
80
|
+
if cache.dc.get("version") != ctx.cfg["cache_version"]:
|
|
81
|
+
logger.info("Index Cache - Detected version change during recheck; resetting cache.")
|
|
82
|
+
cache.reset_cache()
|
|
83
|
+
|
|
84
|
+
updated_time = cache.dc.get("updated", 0)
|
|
85
|
+
if not isinstance(updated_time, (int, float)):
|
|
86
|
+
updated_time = 0
|
|
87
|
+
|
|
88
|
+
if (int(time()) - int(updated_time)) <= 30 and cache.dc.get("version") == ctx.cfg.get("cache_version", None):
|
|
89
|
+
logger.info("Index Cache - State is up-to-date on recheck, skipping.")
|
|
90
|
+
cache.log_stats("post-foreign-index-recheck")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
logger.info("Index Cache - Rechecking cache; starting (re-)index if necessary.")
|
|
94
|
+
try:
|
|
95
|
+
mt_index_configurations(ctx)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Index Cache - Error during recheck: {e}", exc_info=True)
|
|
98
|
+
cache.dc.pop("indexing", None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _wait_for_indexing_and_recheck(ctx: ScriptContext) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Waits for an external indexing to complete or become stale, then rechecks and indexes if needed.
|
|
104
|
+
"""
|
|
105
|
+
logger = ctx.logger
|
|
106
|
+
cache: CacheManager = ctx.cache # type: ignore
|
|
107
|
+
STALE_SECONDS = 180
|
|
108
|
+
POLL_SECONDS = 5
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
# If an error was recorded during indexing, stop waiting and surface it
|
|
113
|
+
if cache.dc.get("indexing_error"):
|
|
114
|
+
logger.error("Index Cache - External indexing signaled an error. Rechecking will not start automatically.")
|
|
115
|
+
cache.dc.pop("indexing", None)
|
|
116
|
+
return
|
|
117
|
+
if not cache.dc.get("indexing"):
|
|
118
|
+
logger.info("Index Cache - External indexing finished (flag cleared). Rechecking.")
|
|
119
|
+
break
|
|
120
|
+
last = int(cache.dc.get("indexing_last_update", 0) or 0)
|
|
121
|
+
if (int(time()) - last) > STALE_SECONDS:
|
|
122
|
+
logger.warning("Index Cache - External indexing appears stale; clearing flag and rechecking.")
|
|
123
|
+
cache.dc.pop("indexing", None)
|
|
124
|
+
break
|
|
125
|
+
except Exception:
|
|
126
|
+
# Conservative: break and recheck if we cannot read the flag
|
|
127
|
+
break
|
|
128
|
+
sleep(POLL_SECONDS)
|
|
129
|
+
|
|
130
|
+
_recheck_and_index_if_needed(ctx)
|
cn_tool/core/base.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# core/base.py
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
import argparse
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from time import perf_counter
|
|
8
|
+
from typing import Any, Callable, Dict, List, NamedTuple, Optional, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .event_bus import EventBus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from cn_tool.utils.display import ThemedConsole
|
|
15
|
+
from cn_tool.utils.cache import CacheManager
|
|
16
|
+
from cn_tool.utils.stats import StatsManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Credentials:
|
|
21
|
+
"""One account: a user name, its password, and where the password came from (``source``).
|
|
22
|
+
|
|
23
|
+
The password is not part of ``repr``, so a log line that formats the object never shows it.
|
|
24
|
+
"""
|
|
25
|
+
username: str
|
|
26
|
+
password: str = field(repr=False)
|
|
27
|
+
source: str = "" # "INFOBLOX_PW", "GPG file /home/alice/infoblox.gpg" or "prompt"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ScriptContext:
|
|
32
|
+
"""A container for shared objects used throughout the script."""
|
|
33
|
+
cfg: Dict[str, Any] = field(repr=False) # .cn as typed: it can hold an [email] password, or one pasted into [auth]
|
|
34
|
+
logger: logging.Logger
|
|
35
|
+
console: "ThemedConsole" # Use quotes for forward reference
|
|
36
|
+
cache: Optional["CacheManager"] # Use quotes for forward reference
|
|
37
|
+
event_bus: EventBus
|
|
38
|
+
username: str
|
|
39
|
+
password: str = field(repr=False) # the TACACS password: repr(ctx) never shows it
|
|
40
|
+
plugins: List["BasePlugin"]
|
|
41
|
+
stats: Optional["StatsManager"] = None
|
|
42
|
+
# Infoblox's own account once resolved; None while unknown, and always None when Infoblox uses the
|
|
43
|
+
# shared login (username/password above).
|
|
44
|
+
infoblox_credentials: Optional[Credentials] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CliResult(NamedTuple):
|
|
48
|
+
"""What ``BaseModule.run_cli`` hands back: the process exit code and the sections to render."""
|
|
49
|
+
exit_code: int
|
|
50
|
+
data: Dict[str, List[Dict[str, Any]]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cli_exit_code(found: bool, invalid: bool, failed: bool) -> int:
|
|
54
|
+
"""Exit status of a command-line run: 3 incomplete, 2 usage error, 0 data, 1 no record.
|
|
55
|
+
|
|
56
|
+
Precedence is 3 > 2 > 0 > 1, so an Infoblox failure or unsaved report is never hidden
|
|
57
|
+
behind "found", and a usage error is never hidden behind data from the valid objects.
|
|
58
|
+
"""
|
|
59
|
+
if failed:
|
|
60
|
+
return 3
|
|
61
|
+
if invalid:
|
|
62
|
+
return 2
|
|
63
|
+
return 0 if found else 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BaseModule(ABC):
|
|
67
|
+
"""Abstract Base Class for all main menu modules."""
|
|
68
|
+
|
|
69
|
+
#: Command under which ``cn <name>`` dispatches to ``run_cli``; None keeps the module menu-only.
|
|
70
|
+
cli_name: Optional[str] = None
|
|
71
|
+
|
|
72
|
+
def __init__(self):
|
|
73
|
+
# Hooks are points where plugins can inject logic.
|
|
74
|
+
# The key is the hook name, the value is a list of callback functions.
|
|
75
|
+
self.hooks: Dict[str, List[Callable]] = {
|
|
76
|
+
'pre_run': [], # Called at the absolute beginning of a module's run method.
|
|
77
|
+
'process_data': [], # Called after initial data processing.
|
|
78
|
+
'pre_render': [], # Called just before creating tables for display.
|
|
79
|
+
'pre_save': [], # Called just before queueing data for saving.
|
|
80
|
+
'post_run': [], # Called at the very end of a module's run method, for cleanup.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def visibility_config_key(self) -> Optional[str]:
|
|
85
|
+
"""
|
|
86
|
+
If a string is returned, this module will only be visible in the menu
|
|
87
|
+
if the corresponding key in the configuration is True.
|
|
88
|
+
e.g., return 'email_enabled'
|
|
89
|
+
"""
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def track_in_stats(self) -> bool:
|
|
94
|
+
"""Whether this module should contribute to shared usage statistics."""
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def menu_key(self) -> str:
|
|
100
|
+
"""The character key to trigger this module from the menu (e.g., '1', 'b')."""
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def menu_title(self) -> str:
|
|
106
|
+
"""The title of the module to display in the menu."""
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
def register_hook(self, hook_name: str, callback: Callable):
|
|
110
|
+
"""Allows plugins to register a callback for a specific hook."""
|
|
111
|
+
if hook_name in self.hooks:
|
|
112
|
+
self.hooks[hook_name].append(callback)
|
|
113
|
+
else:
|
|
114
|
+
print(f"Warning: Attempted to register for unknown hook '{hook_name}' in {self.menu_title}", file=sys.stderr)
|
|
115
|
+
|
|
116
|
+
def execute_hook(self, hook_name: str, ctx: ScriptContext, data: Any) -> Any:
|
|
117
|
+
"""Executes all registered callbacks for a hook, passing data through them."""
|
|
118
|
+
modified_data = data
|
|
119
|
+
for callback in self.hooks.get(hook_name, []):
|
|
120
|
+
try:
|
|
121
|
+
# Get the plugin's name from the bound method for clear logging
|
|
122
|
+
plugin_name = callback.__self__.name
|
|
123
|
+
except AttributeError:
|
|
124
|
+
plugin_name = "Unknown Plugin"
|
|
125
|
+
|
|
126
|
+
hook_start_time = perf_counter()
|
|
127
|
+
# Each callback receives the context and the data, and must return the (potentially modified) data.
|
|
128
|
+
modified_data = callback(ctx, modified_data)
|
|
129
|
+
|
|
130
|
+
hook_end_time = perf_counter()
|
|
131
|
+
duration = round(hook_end_time - hook_start_time, 3)
|
|
132
|
+
|
|
133
|
+
ctx.logger.info(f"Plugin '{plugin_name}' hook '{hook_name}' took {duration} seconds.")
|
|
134
|
+
|
|
135
|
+
return modified_data
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def run(self, ctx: ScriptContext) -> None:
|
|
139
|
+
"""The main entry point for the module's execution."""
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
def run_cli(self, ctx: ScriptContext, args: argparse.Namespace) -> CliResult:
|
|
143
|
+
"""Run once from ``cn <command>`` with the parsed options; the result is rendered by main."""
|
|
144
|
+
ctx.console.print(f"{self.menu_title} is not available from the command line.")
|
|
145
|
+
return CliResult(2, {})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class BasePlugin(ABC):
|
|
149
|
+
"""Abstract Base Class for all plugins."""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def user_configurable_settings(self) -> List[Dict[str, str]]:
|
|
153
|
+
"""
|
|
154
|
+
A list of settings this plugin exposes to the user via the setup module.
|
|
155
|
+
Each dict should contain:
|
|
156
|
+
- 'key': The key in the config context (e.g., 'ad_user')
|
|
157
|
+
- 'prompt': The user-friendly prompt (e.g., 'Active Directory Username')
|
|
158
|
+
"""
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
@abstractmethod
|
|
163
|
+
def name(self) -> str:
|
|
164
|
+
"""A descriptive name for the plugin."""
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
@abstractmethod
|
|
169
|
+
def target_module_name(self) -> str:
|
|
170
|
+
"""The filename of the module this plugin targets (e.g., 'ip_request')."""
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def config_schema(self) -> Dict[str, Dict[str, Any]]:
|
|
175
|
+
"""
|
|
176
|
+
Defines the configuration schema required by this plugin.
|
|
177
|
+
Each plugin can specify its own section, keys, types, and fallbacks.
|
|
178
|
+
Example:
|
|
179
|
+
return {
|
|
180
|
+
'my_plugin_key': {'section': 'myplugin', 'ini_key': 'api_key', 'type': 'str', 'fallback': ''}
|
|
181
|
+
}
|
|
182
|
+
"""
|
|
183
|
+
return {}
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def manages_global_connection(self) -> bool:
|
|
187
|
+
"""
|
|
188
|
+
If True, this plugin manages a persistent, application-wide connection.
|
|
189
|
+
The main application will call connect() on startup and disconnect() on exit.
|
|
190
|
+
"""
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def connect(self, ctx: ScriptContext) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Called once on application startup if manages_global_connection is True.
|
|
196
|
+
Used to initialize persistent resources like API connections.
|
|
197
|
+
"""
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
def disconnect(self, ctx: ScriptContext) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Called once on application exit if manages_global_connection is True.
|
|
203
|
+
Used to clean up persistent resources.
|
|
204
|
+
"""
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
@abstractmethod
|
|
208
|
+
def register(self, module: BaseModule) -> None:
|
|
209
|
+
"""Called by the loader to register the plugin's hooks with its target module."""
|
|
210
|
+
pass
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Simple publish/subscribe event bus used for cross-module notifications."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
import itertools
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Any, Callable, DefaultDict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
EventCallback = Callable[[Any], None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventBus:
|
|
14
|
+
"""Thread-safe in-process event dispatcher."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
|
17
|
+
self._logger = logger
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
self._subscribers: DefaultDict[str, List[Tuple[int, EventCallback]]] = defaultdict(list)
|
|
20
|
+
self._token_source = itertools.count(1)
|
|
21
|
+
|
|
22
|
+
def subscribe(self, event_name: str, callback: EventCallback) -> int:
|
|
23
|
+
"""Register *callback* to be invoked when *event_name* is published."""
|
|
24
|
+
if not callable(callback): # pragma: no cover - defensive guard
|
|
25
|
+
raise TypeError("callback must be callable")
|
|
26
|
+
token = next(self._token_source)
|
|
27
|
+
with self._lock:
|
|
28
|
+
self._subscribers[event_name].append((token, callback))
|
|
29
|
+
return token
|
|
30
|
+
|
|
31
|
+
def unsubscribe(self, token: int) -> bool:
|
|
32
|
+
"""Remove a previously registered callback using its subscription *token*."""
|
|
33
|
+
with self._lock:
|
|
34
|
+
for event_name, callbacks in list(self._subscribers.items()):
|
|
35
|
+
for index, (stored_token, _) in enumerate(callbacks):
|
|
36
|
+
if stored_token == token:
|
|
37
|
+
callbacks.pop(index)
|
|
38
|
+
if not callbacks:
|
|
39
|
+
self._subscribers.pop(event_name, None)
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def publish(self, event_name: str, payload: Any | None = None) -> None:
|
|
44
|
+
"""Invoke all listeners subscribed to *event_name* with *payload*."""
|
|
45
|
+
with self._lock:
|
|
46
|
+
callbacks = list(self._subscribers.get(event_name, ()))
|
|
47
|
+
for _, callback in callbacks:
|
|
48
|
+
try:
|
|
49
|
+
callback(payload)
|
|
50
|
+
except Exception: # pragma: no cover - logging guard
|
|
51
|
+
if self._logger:
|
|
52
|
+
self._logger.exception("Event handler failure for '%s'", event_name)
|
|
53
|
+
|
|
54
|
+
def clear(self) -> None:
|
|
55
|
+
"""Remove every subscriber from the bus."""
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._subscribers.clear()
|
cn_tool/core/loader.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# core/loader.py
|
|
2
|
+
import os
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
from cn_tool.core.base import BaseModule, BasePlugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _merge_plugin_schema(schema: Dict[str, Any], plugin_schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
14
|
+
"""Merge plugin schema entries without discarding required base metadata."""
|
|
15
|
+
merged = schema.copy()
|
|
16
|
+
for key, plugin_spec in plugin_schema.items():
|
|
17
|
+
current_spec = merged.get(key)
|
|
18
|
+
if isinstance(current_spec, dict) and isinstance(plugin_spec, dict):
|
|
19
|
+
merged[key] = {**current_spec, **plugin_spec}
|
|
20
|
+
else:
|
|
21
|
+
merged[key] = plugin_spec
|
|
22
|
+
return merged
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def collect_plugin_schemas(master_schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
26
|
+
"""Harvest config_schema from every plugin without registering hooks.
|
|
27
|
+
|
|
28
|
+
Returns ``master_schema`` extended with each plugin's contributed keys.
|
|
29
|
+
Used by the standalone CLIs bundled with cn-tool so they see the same schema surface as
|
|
30
|
+
cn-tool's main entry point without pulling in module loading or hook
|
|
31
|
+
registration. Plugin files are walked in ``sorted(os.listdir(...))`` order
|
|
32
|
+
-- the same order ``_load_and_register_plugins`` uses -- so merge precedence
|
|
33
|
+
matches between the two callers.
|
|
34
|
+
|
|
35
|
+
NOTE: this instantiates each ``BasePlugin`` subclass to read its
|
|
36
|
+
``config_schema`` property. Plugin authors must keep ``__init__`` free of
|
|
37
|
+
I/O or thread/process creation, otherwise those CLIs' runtime builds incur
|
|
38
|
+
those side effects on every invocation.
|
|
39
|
+
"""
|
|
40
|
+
plugins_path = os.path.join(os.path.dirname(__file__), "..", "plugins")
|
|
41
|
+
schema = master_schema.copy()
|
|
42
|
+
if not os.path.isdir(plugins_path):
|
|
43
|
+
return schema
|
|
44
|
+
for filename in sorted(os.listdir(plugins_path)):
|
|
45
|
+
if not filename.endswith(".py") or filename.startswith("__"):
|
|
46
|
+
continue
|
|
47
|
+
module_path = f"cn_tool.plugins.{filename[:-3]}"
|
|
48
|
+
try:
|
|
49
|
+
mod = importlib.import_module(module_path)
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
logger.debug("collect_plugin_schemas: import failed %s: %s", module_path, exc)
|
|
52
|
+
continue
|
|
53
|
+
for _, obj in inspect.getmembers(mod):
|
|
54
|
+
if not inspect.isclass(obj) or obj is BasePlugin:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
if not issubclass(obj, BasePlugin):
|
|
58
|
+
continue
|
|
59
|
+
except TypeError:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
plugin_schema = obj().config_schema or {}
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"collect_plugin_schemas: could not read schema from %s: %s",
|
|
66
|
+
obj.__name__,
|
|
67
|
+
exc,
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
schema = _merge_plugin_schema(schema, plugin_schema)
|
|
71
|
+
return schema
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_modules_and_plugins(master_schema: Dict[str, Any]) -> tuple[Dict[str, BaseModule], list[BasePlugin], Dict[str, Any]]:
|
|
75
|
+
"""
|
|
76
|
+
Discovers, loads, and registers all modules and plugins.
|
|
77
|
+
It also collects configuration schemas from plugins and merges them.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
master_schema: The base configuration schema of the application.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A tuple containing:
|
|
84
|
+
- A dictionary of loaded and configured module instances.
|
|
85
|
+
- The updated master schema including plugin configurations.
|
|
86
|
+
"""
|
|
87
|
+
# Start with a copy of the base schema
|
|
88
|
+
updated_schema = master_schema.copy()
|
|
89
|
+
all_plugins: list[BasePlugin] = []
|
|
90
|
+
|
|
91
|
+
modules_path = os.path.join(os.path.dirname(__file__), '..', 'modules')
|
|
92
|
+
plugins_path = os.path.join(os.path.dirname(__file__), '..', 'plugins')
|
|
93
|
+
|
|
94
|
+
modules = _load_modules(modules_path)
|
|
95
|
+
# The plugin loader now returns the updated schema
|
|
96
|
+
updated_schema = _load_and_register_plugins(plugins_path, modules, updated_schema, all_plugins)
|
|
97
|
+
|
|
98
|
+
# Return a dictionary keyed by menu_key for easy access in the main loop
|
|
99
|
+
modules_by_key = {mod.menu_key: mod for mod in modules.values()}
|
|
100
|
+
|
|
101
|
+
return modules_by_key, all_plugins, updated_schema
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load_modules(path: str) -> Dict[str, BaseModule]:
|
|
105
|
+
"""Loads all BaseModule subclasses from a given directory."""
|
|
106
|
+
loaded_modules: Dict[str, BaseModule] = {}
|
|
107
|
+
for filename in sorted(os.listdir(path)):
|
|
108
|
+
if filename.endswith('.py') and not filename.startswith('__'):
|
|
109
|
+
module_name = filename[:-3]
|
|
110
|
+
module_path = f"cn_tool.modules.{module_name}"
|
|
111
|
+
try:
|
|
112
|
+
module = importlib.import_module(module_path)
|
|
113
|
+
for name, obj in inspect.getmembers(module):
|
|
114
|
+
try:
|
|
115
|
+
if inspect.isclass(obj) and issubclass(obj, BaseModule) and obj is not BaseModule:
|
|
116
|
+
instance = obj()
|
|
117
|
+
logger.info(f" -> Loaded module: {instance.menu_title} ({module_name})")
|
|
118
|
+
loaded_modules[module_name] = instance # Instantiate the module
|
|
119
|
+
except TypeError:
|
|
120
|
+
continue
|
|
121
|
+
except ImportError as e:
|
|
122
|
+
logger.error("Error loading module %s: %s", module_path, e)
|
|
123
|
+
return loaded_modules
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _load_and_register_plugins(path: str, modules: Dict[str, BaseModule], schema: Dict[str, Any], plugin_list: list[BasePlugin]) -> Dict[str, Any]:
|
|
127
|
+
"""
|
|
128
|
+
Loads plugins, registers their hooks, and merges their config schemas.
|
|
129
|
+
"""
|
|
130
|
+
for filename in sorted(os.listdir(path)):
|
|
131
|
+
if filename.endswith('.py') and not filename.startswith('__'):
|
|
132
|
+
module_name = filename[:-3]
|
|
133
|
+
module_path = f"cn_tool.plugins.{module_name}"
|
|
134
|
+
try:
|
|
135
|
+
module = importlib.import_module(module_path)
|
|
136
|
+
for name, obj in inspect.getmembers(module):
|
|
137
|
+
if inspect.isclass(obj):
|
|
138
|
+
try:
|
|
139
|
+
if issubclass(obj, BasePlugin) and obj is not BasePlugin:
|
|
140
|
+
plugin = obj()
|
|
141
|
+
logger.info(f" -> Loaded plugin: {plugin.name}")
|
|
142
|
+
plugin_list.append(plugin)
|
|
143
|
+
|
|
144
|
+
plugin_schema = plugin.config_schema
|
|
145
|
+
if plugin_schema:
|
|
146
|
+
logger.info("Loading config schema from plugin '%s'", plugin.name)
|
|
147
|
+
schema = _merge_plugin_schema(schema, plugin_schema)
|
|
148
|
+
|
|
149
|
+
target_name = plugin.target_module_name
|
|
150
|
+
# Only attempt to register if the plugin specifies a target.
|
|
151
|
+
if target_name:
|
|
152
|
+
target_module = modules.get(target_name)
|
|
153
|
+
if target_module:
|
|
154
|
+
plugin.register(target_module)
|
|
155
|
+
else:
|
|
156
|
+
# This warning will now only show for plugins that
|
|
157
|
+
# SPECIFY a target that does not exist.
|
|
158
|
+
logger.warning("Plugin '%s' targets non-existent module '%s'", plugin.name, target_name)
|
|
159
|
+
# If target_name is empty, we do nothing. This is expected for lifecycle plugins.
|
|
160
|
+
except TypeError:
|
|
161
|
+
continue
|
|
162
|
+
except ImportError as e:
|
|
163
|
+
logger.error("Error loading plugin %s: %s", module_path, e)
|
|
164
|
+
return schema
|