hpavil 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.
hpavil-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: hpavil
3
+ Version: 0.1.0
4
+ Summary: Solace bridge inventory backend — parses broker .cli configs into a queryable bridge and topology inventory.
5
+ Requires-Python: >=3.9
@@ -0,0 +1,6 @@
1
+ """Solace bridge inventory backend."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
6
+
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
5
+
@@ -0,0 +1,2 @@
1
+ """REST API adapter."""
2
+
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+ from typing import Any, Callable, Dict, Optional
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ from ..app import BridgeInventoryService
9
+
10
+
11
+ class JsonApiHandler(BaseHTTPRequestHandler):
12
+ service: BridgeInventoryService
13
+
14
+ def do_GET(self) -> None:
15
+ self._handle("GET")
16
+
17
+ def do_POST(self) -> None:
18
+ self._handle("POST")
19
+
20
+ def log_message(self, format: str, *args: object) -> None:
21
+ return
22
+
23
+ def _handle(self, method: str) -> None:
24
+ parsed = urlparse(self.path)
25
+ try:
26
+ if method == "GET" and parsed.path == "/health":
27
+ self._json(200, self.service.health())
28
+ elif method == "POST" and parsed.path == "/scan":
29
+ self._json(200, self.service.scan())
30
+ elif method == "GET" and parsed.path == "/summary":
31
+ self._json(200, self.service.summary())
32
+ elif method == "GET" and parsed.path == "/source-files":
33
+ self._json(200, {"items": self.service.source_files()})
34
+ elif method == "GET" and parsed.path == "/brokers":
35
+ self._json(200, {"items": self.service.brokers()})
36
+ elif method == "GET" and parsed.path == "/bridges":
37
+ query = parse_qs(parsed.query)
38
+ bridge_type = query.get("type", [None])[0]
39
+ self._json(200, {"items": self.service.bridges(bridge_type=bridge_type)})
40
+ elif method == "GET" and parsed.path.startswith("/bridges/"):
41
+ bridge_id = parsed.path.removeprefix("/bridges/")
42
+ bridge = self.service.bridge(bridge_id)
43
+ if bridge is None:
44
+ self._json(404, {"error": "bridge_not_found", "id": bridge_id})
45
+ else:
46
+ self._json(200, bridge)
47
+ elif method == "GET" and parsed.path == "/bridge-relationships":
48
+ self._json(200, {"items": self.service.relationships()})
49
+ elif method == "GET" and parsed.path == "/topology-relationships":
50
+ self._json(200, {"items": self.service.topology_relationships()})
51
+ elif method == "GET" and parsed.path == "/bridge-port-remediation-plan":
52
+ query = parse_qs(parsed.query)
53
+ broker = query.get("broker", [None])[0]
54
+ desired_port = _int_query(query, "desired_port", 55443)
55
+ self._json(
56
+ 200,
57
+ self.service.bridge_port_remediation_plan(
58
+ broker=broker, desired_port=desired_port
59
+ ),
60
+ )
61
+ elif method == "GET" and parsed.path == "/parser-warnings":
62
+ self._json(200, {"items": self.service.warnings()})
63
+ else:
64
+ self._json(404, {"error": "not_found", "path": parsed.path})
65
+ except Exception as exc:
66
+ self._json(500, {"error": "internal_error", "message": str(exc)})
67
+
68
+ def _json(self, status: int, payload: Any) -> None:
69
+ data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
70
+ self.send_response(status)
71
+ self.send_header("Content-Type", "application/json; charset=utf-8")
72
+ self.send_header("Content-Length", str(len(data)))
73
+ self.end_headers()
74
+ self.wfile.write(data)
75
+
76
+
77
+ def make_server(
78
+ service: BridgeInventoryService, host: str = "127.0.0.1", port: int = 8787
79
+ ) -> ThreadingHTTPServer:
80
+ class Handler(JsonApiHandler):
81
+ pass
82
+
83
+ Handler.service = service
84
+ return ThreadingHTTPServer((host, port), Handler)
85
+
86
+
87
+ def _int_query(query: Dict[str, list[str]], name: str, default: int) -> int:
88
+ values = query.get(name)
89
+ if not values or values[0] is None:
90
+ return default
91
+ try:
92
+ return int(values[0])
93
+ except ValueError:
94
+ return default
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .core.parser import SolaceCliParser
7
+ from .core.remediation import BridgePortRemediationPlanner
8
+ from .core.resolver import BridgeRelationshipResolver
9
+ from .core.topology import TopologyParser, TopologyResolver
10
+ from .infra.file_scanner import CliDirectoryScanner
11
+ from .infra.sqlite_store import SQLiteInventoryStore
12
+
13
+
14
+ class BridgeInventoryService:
15
+ def __init__(self, cli_dir: Path, db_path: Path):
16
+ self.cli_dir = cli_dir
17
+ self.db_path = db_path
18
+ self.scanner = CliDirectoryScanner(cli_dir)
19
+ self.parser = SolaceCliParser()
20
+ self.resolver = BridgeRelationshipResolver()
21
+ self.topology_parser = TopologyParser()
22
+ self.topology_resolver = TopologyResolver()
23
+ self.bridge_port_remediation_planner = BridgePortRemediationPlanner()
24
+ self.store = SQLiteInventoryStore(db_path)
25
+
26
+ def scan(self) -> Dict[str, Any]:
27
+ source_files = self.scanner.scan()
28
+ parsed = []
29
+ topology_configs = []
30
+ for source_file in source_files:
31
+ broker_config = self.parser.parse_file(source_file.path)
32
+ parsed.append(broker_config)
33
+ topology_configs.append(
34
+ self.topology_parser.parse_file(
35
+ source_file.path, broker_config.identity.broker_name
36
+ )
37
+ )
38
+ relationships = self.resolver.resolve(parsed)
39
+ topology_relationships = self.topology_resolver.resolve(topology_configs)
40
+ self.store.replace_inventory(
41
+ source_files, parsed, relationships, topology_relationships
42
+ )
43
+ summary = self.store.summary()
44
+ summary["cli_dir"] = str(self.cli_dir.resolve())
45
+ summary["db_path"] = str(self.db_path.resolve())
46
+ return summary
47
+
48
+ def health(self) -> Dict[str, Any]:
49
+ self.store.initialize()
50
+ return {"status": "ok", "cli_dir": str(self.cli_dir), "db_path": str(self.db_path)}
51
+
52
+ def source_files(self) -> List[Dict[str, Any]]:
53
+ return self.store.list_source_files()
54
+
55
+ def brokers(self) -> List[Dict[str, Any]]:
56
+ return self.store.list_brokers()
57
+
58
+ def bridges(self, bridge_type: Optional[str] = None) -> List[Dict[str, Any]]:
59
+ return self.store.list_bridges(bridge_type=bridge_type)
60
+
61
+ def bridge(self, bridge_id: str) -> Optional[Dict[str, Any]]:
62
+ return self.store.get_bridge(bridge_id)
63
+
64
+ def relationships(self) -> List[Dict[str, Any]]:
65
+ return self.store.list_relationships()
66
+
67
+ def topology_relationships(self) -> List[Dict[str, Any]]:
68
+ return self.store.list_topology_relationships()
69
+
70
+ def bridge_port_remediation_plan(
71
+ self, broker: Optional[str] = None, desired_port: int = 55443
72
+ ) -> Dict[str, Any]:
73
+ return self.bridge_port_remediation_planner.plan(
74
+ bridges=self.bridges("message_vpn_bridge"),
75
+ relationships=self.relationships(),
76
+ broker=broker,
77
+ desired_port=desired_port,
78
+ )
79
+
80
+ def warnings(self) -> List[Dict[str, Any]]:
81
+ return self.store.list_warnings()
82
+
83
+ def summary(self) -> Dict[str, Any]:
84
+ self.store.initialize()
85
+ summary = self.store.summary()
86
+ summary["cli_dir"] = str(self.cli_dir.resolve())
87
+ summary["db_path"] = str(self.db_path.resolve())
88
+ return summary
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .app import BridgeInventoryService
8
+ from .api.server import make_server
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ parser = argparse.ArgumentParser(description="Solace bridge inventory backend")
13
+ subcommands = parser.add_subparsers(dest="command", required=True)
14
+
15
+ scan_parser = subcommands.add_parser("scan", help="Scan local .cli files")
16
+ add_common_args(scan_parser)
17
+
18
+ serve_parser = subcommands.add_parser("serve", help="Run the REST API")
19
+ add_common_args(serve_parser)
20
+ serve_parser.add_argument("--host", default="127.0.0.1")
21
+ serve_parser.add_argument("--port", type=int, default=8787)
22
+ serve_parser.add_argument(
23
+ "--no-initial-scan",
24
+ action="store_true",
25
+ help="Start API without scanning first",
26
+ )
27
+
28
+ args = parser.parse_args(argv)
29
+ service = BridgeInventoryService(Path(args.cli_dir), Path(args.db))
30
+
31
+ if args.command == "scan":
32
+ print(json.dumps(service.scan(), indent=2, sort_keys=True))
33
+ return 0
34
+ if args.command == "serve":
35
+ if not args.no_initial_scan:
36
+ summary = service.scan()
37
+ print(json.dumps({"initial_scan": summary}, indent=2, sort_keys=True))
38
+ server = make_server(service, host=args.host, port=args.port)
39
+ url = f"http://{args.host}:{args.port}"
40
+ print(f"Serving bridge inventory API at {url}")
41
+ print("Endpoints: /health /summary /bridges /bridge-relationships /topology-relationships /bridge-port-remediation-plan")
42
+ try:
43
+ server.serve_forever()
44
+ except KeyboardInterrupt:
45
+ print("\nStopping server")
46
+ finally:
47
+ server.server_close()
48
+ return 0
49
+ return 2
50
+
51
+
52
+ def add_common_args(parser: argparse.ArgumentParser) -> None:
53
+ parser.add_argument(
54
+ "--cli-dir",
55
+ default=".",
56
+ help="Directory containing local .cli files",
57
+ )
58
+ parser.add_argument(
59
+ "--db",
60
+ default=".bridge_inventory.sqlite",
61
+ help="SQLite database path",
62
+ )
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main())
@@ -0,0 +1,2 @@
1
+ """Core domain model, parser, and resolver."""
2
+
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ def without_none(value: Dict[str, Any]) -> Dict[str, Any]:
8
+ return {k: v for k, v in value.items() if v is not None}
9
+
10
+
11
+ @dataclass
12
+ class ParserWarning:
13
+ source_file: str
14
+ line_number: int
15
+ code: str
16
+ message: str
17
+
18
+ def to_dict(self) -> Dict[str, Any]:
19
+ return asdict(self)
20
+
21
+
22
+ @dataclass
23
+ class BrokerIdentity:
24
+ source_file: str
25
+ broker_name: str
26
+ router_name: Optional[str] = None
27
+ version: Optional[str] = None
28
+ generated_at: Optional[str] = None
29
+ redacted: Optional[bool] = None
30
+ aliases: List[str] = field(default_factory=list)
31
+
32
+ def all_aliases(self) -> List[str]:
33
+ aliases = [self.broker_name]
34
+ if self.router_name:
35
+ aliases.append(self.router_name)
36
+ aliases.extend(self.aliases)
37
+ seen = set()
38
+ result = []
39
+ for alias in aliases:
40
+ if alias and alias not in seen:
41
+ seen.add(alias)
42
+ result.append(alias)
43
+ return result
44
+
45
+ def to_dict(self) -> Dict[str, Any]:
46
+ return without_none(asdict(self))
47
+
48
+
49
+ @dataclass
50
+ class MessageVpn:
51
+ broker_name: str
52
+ name: str
53
+ admin_state: Optional[str] = None
54
+ replication_state: Optional[str] = None
55
+
56
+ def to_dict(self) -> Dict[str, Any]:
57
+ return without_none(asdict(self))
58
+
59
+
60
+ @dataclass
61
+ class MessageVpnBridgeTarget:
62
+ remote_vpn: str
63
+ remote_kind: Optional[str] = None
64
+ remote_value: Optional[str] = None
65
+ remote_broker: Optional[str] = None
66
+ admin_state: Optional[str] = None
67
+ tls_enabled: Optional[bool] = None
68
+ compressed_data_enabled: Optional[bool] = None
69
+ message_spool_queue: Optional[str] = None
70
+ window_size: Optional[int] = None
71
+ connect_order: Optional[int] = None
72
+ unidirectional_client_profile: Optional[str] = None
73
+ source_line: Optional[int] = None
74
+
75
+ def to_dict(self) -> Dict[str, Any]:
76
+ return without_none(asdict(self))
77
+
78
+
79
+ @dataclass
80
+ class MessageVpnBridge:
81
+ broker_name: str
82
+ source_vpn: str
83
+ bridge_name: str
84
+ redundancy: Optional[str] = None
85
+ admin_state: Optional[str] = None
86
+ max_ttl: Optional[int] = None
87
+ auth_scheme: Optional[str] = None
88
+ client_username: Optional[str] = None
89
+ retry_count: Optional[int] = None
90
+ retry_delay: Optional[int] = None
91
+ source_line: Optional[int] = None
92
+ targets: List[MessageVpnBridgeTarget] = field(default_factory=list)
93
+
94
+ @property
95
+ def bridge_type(self) -> str:
96
+ return "message_vpn_bridge"
97
+
98
+ def to_dict(self) -> Dict[str, Any]:
99
+ data = without_none(asdict(self))
100
+ data["bridge_type"] = self.bridge_type
101
+ return data
102
+
103
+
104
+ @dataclass
105
+ class VpnReplicationBridge:
106
+ broker_name: str
107
+ source_vpn: str
108
+ bridge_name: str = "#MSGVPN_REPLICATION_BRIDGE"
109
+ admin_state: Optional[str] = None
110
+ replication_state: Optional[str] = None
111
+ transaction_replication_mode: Optional[str] = None
112
+ auth_scheme: Optional[str] = None
113
+ client_username: Optional[str] = None
114
+ tls_enabled: Optional[bool] = None
115
+ compressed_data_enabled: Optional[bool] = None
116
+ window_size: Optional[int] = None
117
+ retry_delay: Optional[int] = None
118
+ unidirectional_client_profile: Optional[str] = None
119
+ queue_max_spool_usage: Optional[int] = None
120
+ queue_reject_msg_to_sender_on_discard: Optional[bool] = None
121
+ source_line: Optional[int] = None
122
+
123
+ @property
124
+ def bridge_type(self) -> str:
125
+ return "vpn_replication_bridge"
126
+
127
+ def to_dict(self) -> Dict[str, Any]:
128
+ data = without_none(asdict(self))
129
+ data["bridge_type"] = self.bridge_type
130
+ return data
131
+
132
+
133
+ @dataclass
134
+ class ConfigSyncReplicationBridge:
135
+ broker_name: str
136
+ bridge_name: str = "#CONFIG_SYNC_REPLICATION_BRIDGE"
137
+ admin_state: Optional[str] = None
138
+ remote_broker: Optional[str] = None
139
+ replication_mate_connect_via: Optional[str] = None
140
+ replication_mate_virtual_router: Optional[str] = None
141
+ auth_scheme: Optional[str] = None
142
+ tls_enabled: Optional[bool] = None
143
+ compressed_data_enabled: Optional[bool] = None
144
+ window_size: Optional[int] = None
145
+ retry_delay: Optional[int] = None
146
+ source_line: Optional[int] = None
147
+
148
+ @property
149
+ def bridge_type(self) -> str:
150
+ return "config_sync_replication_bridge"
151
+
152
+ def to_dict(self) -> Dict[str, Any]:
153
+ data = without_none(asdict(self))
154
+ data["bridge_type"] = self.bridge_type
155
+ return data
156
+
157
+
158
+ @dataclass
159
+ class ParsedBrokerConfig:
160
+ identity: BrokerIdentity
161
+ message_vpns: List[MessageVpn] = field(default_factory=list)
162
+ message_vpn_bridges: List[MessageVpnBridge] = field(default_factory=list)
163
+ vpn_replication_bridges: List[VpnReplicationBridge] = field(default_factory=list)
164
+ config_sync_replication_bridge: Optional[ConfigSyncReplicationBridge] = None
165
+ warnings: List[ParserWarning] = field(default_factory=list)
166
+
167
+ def to_dict(self) -> Dict[str, Any]:
168
+ return {
169
+ "identity": self.identity.to_dict(),
170
+ "message_vpns": [x.to_dict() for x in self.message_vpns],
171
+ "message_vpn_bridges": [x.to_dict() for x in self.message_vpn_bridges],
172
+ "vpn_replication_bridges": [
173
+ x.to_dict() for x in self.vpn_replication_bridges
174
+ ],
175
+ "config_sync_replication_bridge": (
176
+ self.config_sync_replication_bridge.to_dict()
177
+ if self.config_sync_replication_bridge
178
+ else None
179
+ ),
180
+ "warnings": [x.to_dict() for x in self.warnings],
181
+ }
182
+
183
+
184
+ @dataclass
185
+ class BridgeRelationship:
186
+ relationship_type: str
187
+ direction: str
188
+ source_broker: str
189
+ bridge_name: str
190
+ source_vpn: Optional[str] = None
191
+ remote_broker: Optional[str] = None
192
+ remote_vpn: Optional[str] = None
193
+ remote_endpoint: Optional[str] = None
194
+ status: str = "unresolved"
195
+ counterpart_bridge_name: Optional[str] = None
196
+ notes: List[str] = field(default_factory=list)
197
+ details: Dict[str, Any] = field(default_factory=dict)
198
+
199
+ def to_dict(self) -> Dict[str, Any]:
200
+ return without_none(asdict(self))
201
+
202
+
203
+ @dataclass
204
+ class Inventory:
205
+ brokers: List[ParsedBrokerConfig]
206
+ relationships: List[BridgeRelationship]
207
+
208
+ def to_dict(self) -> Dict[str, Any]:
209
+ return {
210
+ "brokers": [x.to_dict() for x in self.brokers],
211
+ "relationships": [x.to_dict() for x in self.relationships],
212
+ }