sshplex 1.7.0__tar.gz → 1.8.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.
- {sshplex-1.7.0/sshplex.egg-info → sshplex-1.8.0}/PKG-INFO +4 -5
- {sshplex-1.7.0 → sshplex-1.8.0}/README.md +1 -1
- {sshplex-1.7.0 → sshplex-1.8.0}/pyproject.toml +3 -4
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/__init__.py +1 -1
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/cli.py +1 -1
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/cache.py +29 -23
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/config.py +85 -26
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/onboarding/wizard.py +30 -51
- sshplex-1.8.0/sshplex/lib/sot/base.py +108 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/factory.py +122 -84
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/git.py +8 -25
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/netbox.py +17 -2
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/config_editor.py +40 -64
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/host_selector.py +16 -5
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/session_manager.py +48 -48
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/sshplex_connector.py +46 -21
- {sshplex-1.7.0 → sshplex-1.8.0/sshplex.egg-info}/PKG-INFO +4 -5
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/SOURCES.txt +1 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_cache.py +7 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_config.py +0 -2
- sshplex-1.8.0/tests/test_sshplex_connector.py +84 -0
- sshplex-1.7.0/sshplex/lib/sot/base.py +0 -57
- {sshplex-1.7.0 → sshplex-1.8.0}/LICENSE +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/MANIFEST.in +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/setup.cfg +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/config-template.yaml +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/__init__.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/commands.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/logger.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/__init__.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/base.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/iterm2_native.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/tmux.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/onboarding/__init__.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/__init__.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/ansible.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/consul.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/static.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/__init__.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/utils/__init__.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/utils/iterm2.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/utils/ssh_config.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/main.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/dependency_links.txt +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/entry_points.txt +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/requires.txt +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/top_level.txt +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_commands.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_iterm2_session_manager.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_main.py +0 -0
- {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_ui_config_editor_columns.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshplex
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Multiplex your SSH connections with style
|
|
5
5
|
Author-email: MJAHED Sabri <contact@sabrimjahed.com>
|
|
6
6
|
License: MIT
|
|
@@ -17,15 +17,14 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
17
17
|
Classifier: Operating System :: POSIX :: Linux
|
|
18
18
|
Classifier: Operating System :: MacOS
|
|
19
19
|
Classifier: Programming Language :: Python :: 3
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
22
20
|
Classifier: Programming Language :: Python :: 3.10
|
|
23
21
|
Classifier: Programming Language :: Python :: 3.11
|
|
24
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
24
|
Classifier: Topic :: System :: Networking
|
|
26
25
|
Classifier: Topic :: System :: Systems Administration
|
|
27
26
|
Classifier: Topic :: Terminals
|
|
28
|
-
Requires-Python: >=3.
|
|
27
|
+
Requires-Python: >=3.10
|
|
29
28
|
Description-Content-Type: text/markdown
|
|
30
29
|
License-File: LICENSE
|
|
31
30
|
Requires-Dist: pynetbox==7.6.1
|
|
@@ -90,7 +89,7 @@ sshplex
|
|
|
90
89
|
|
|
91
90
|
### Prerequisites
|
|
92
91
|
|
|
93
|
-
- Python 3.
|
|
92
|
+
- Python 3.10+
|
|
94
93
|
- tmux (Linux/macOS) and/or iTerm2 (macOS)
|
|
95
94
|
- SSH key configured for target hosts
|
|
96
95
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sshplex"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.8.0"
|
|
8
8
|
description = "Multiplex your SSH connections with style"
|
|
9
9
|
authors = [{name = "MJAHED Sabri", email = "contact@sabrimjahed.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -18,17 +18,16 @@ classifiers = [
|
|
|
18
18
|
"Operating System :: POSIX :: Linux",
|
|
19
19
|
"Operating System :: MacOS",
|
|
20
20
|
"Programming Language :: Python :: 3",
|
|
21
|
-
"Programming Language :: Python :: 3.8",
|
|
22
|
-
"Programming Language :: Python :: 3.9",
|
|
23
21
|
"Programming Language :: Python :: 3.10",
|
|
24
22
|
"Programming Language :: Python :: 3.11",
|
|
25
23
|
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
26
25
|
"Topic :: System :: Networking",
|
|
27
26
|
"Topic :: System :: Systems Administration",
|
|
28
27
|
"Topic :: Terminals",
|
|
29
28
|
]
|
|
30
29
|
keywords = ["ssh", "tmux", "multiplexer", "netbox", "tui", "terminal"]
|
|
31
|
-
requires-python = ">=3.
|
|
30
|
+
requires-python = ">=3.10"
|
|
32
31
|
dependencies = [
|
|
33
32
|
"pynetbox==7.6.1",
|
|
34
33
|
"textual==8.0.0",
|
|
@@ -143,7 +143,7 @@ def list_providers(config: Any, logger: Any) -> int:
|
|
|
143
143
|
branch = provider.branch or "main"
|
|
144
144
|
inventory_format = provider.inventory_format or "static"
|
|
145
145
|
print(f" Repo: {provider.repo_url} [{branch}, {inventory_format}]")
|
|
146
|
-
source_pattern = provider.source_pattern or
|
|
146
|
+
source_pattern = provider.source_pattern or "hosts/**/*.y*ml"
|
|
147
147
|
print(f" Source: {source_pattern}")
|
|
148
148
|
elif provider.type == "static" and provider.hosts:
|
|
149
149
|
print(f" Hosts: {len(provider.hosts)} defined")
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""SSHplex host cache management for optimized startup performance."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import os
|
|
5
|
+
import tempfile
|
|
4
6
|
import threading
|
|
5
7
|
from datetime import datetime, timedelta
|
|
6
8
|
from pathlib import Path
|
|
@@ -28,9 +30,9 @@ class HostCache:
|
|
|
28
30
|
self.logger = get_logger()
|
|
29
31
|
|
|
30
32
|
if cache_dir is None:
|
|
31
|
-
cache_dir =
|
|
33
|
+
cache_dir = "~/.cache/sshplex"
|
|
32
34
|
|
|
33
|
-
self.cache_dir = Path(cache_dir)
|
|
35
|
+
self.cache_dir = Path(cache_dir).expanduser()
|
|
34
36
|
self.cache_ttl = timedelta(hours=cache_ttl_hours)
|
|
35
37
|
self.cache_file = self.cache_dir / "hosts.yaml"
|
|
36
38
|
self.metadata_file = self.cache_dir / "cache_metadata.yaml"
|
|
@@ -70,19 +72,37 @@ class HostCache:
|
|
|
70
72
|
with open(self.metadata_file) as f:
|
|
71
73
|
metadata = yaml.safe_load(f)
|
|
72
74
|
|
|
73
|
-
if not metadata or 'timestamp' not in metadata:
|
|
75
|
+
if not isinstance(metadata, dict) or 'timestamp' not in metadata:
|
|
74
76
|
return False
|
|
75
77
|
|
|
76
78
|
cache_time = datetime.fromisoformat(metadata['timestamp'])
|
|
77
79
|
return datetime.now() - cache_time < self.cache_ttl
|
|
78
80
|
|
|
79
|
-
except (yaml.YAMLError, ValueError, KeyError) as e:
|
|
81
|
+
except (yaml.YAMLError, ValueError, KeyError, OSError) as e:
|
|
80
82
|
self.logger.warning(f"Failed to validate cache: {e}")
|
|
81
83
|
return False
|
|
82
84
|
except Exception as e:
|
|
83
85
|
self.logger.error(f"Unexpected error validating cache: {e}")
|
|
84
86
|
return False
|
|
85
87
|
|
|
88
|
+
def _atomic_write_yaml(self, file_path: Path, payload: Any) -> None:
|
|
89
|
+
"""Write YAML payload atomically to avoid partial/corrupt cache writes."""
|
|
90
|
+
fd, temp_path_text = tempfile.mkstemp(
|
|
91
|
+
prefix=f".{file_path.name}.",
|
|
92
|
+
suffix=".tmp",
|
|
93
|
+
dir=str(self.cache_dir),
|
|
94
|
+
text=True,
|
|
95
|
+
)
|
|
96
|
+
temp_path = Path(temp_path_text)
|
|
97
|
+
try:
|
|
98
|
+
with os.fdopen(fd, "w") as handle:
|
|
99
|
+
yaml.safe_dump(payload, handle, default_flow_style=False, sort_keys=True)
|
|
100
|
+
os.replace(temp_path, file_path)
|
|
101
|
+
finally:
|
|
102
|
+
if temp_path.exists():
|
|
103
|
+
with contextlib.suppress(OSError):
|
|
104
|
+
temp_path.unlink()
|
|
105
|
+
|
|
86
106
|
def save_hosts(self, hosts: List[Host], provider_info: Dict[str, Any]) -> bool:
|
|
87
107
|
"""Save hosts to cache with metadata.
|
|
88
108
|
|
|
@@ -95,19 +115,10 @@ class HostCache:
|
|
|
95
115
|
"""
|
|
96
116
|
with self._lock:
|
|
97
117
|
try:
|
|
98
|
-
|
|
99
|
-
hosts_data = []
|
|
100
|
-
for host in hosts:
|
|
101
|
-
host_dict = {
|
|
102
|
-
'name': host.name,
|
|
103
|
-
'ip': host.ip,
|
|
104
|
-
'metadata': host.metadata
|
|
105
|
-
}
|
|
106
|
-
hosts_data.append(host_dict)
|
|
118
|
+
hosts_data = [host.to_dict() for host in hosts]
|
|
107
119
|
|
|
108
120
|
# Save hosts data
|
|
109
|
-
|
|
110
|
-
yaml.dump(hosts_data, f, default_flow_style=False, sort_keys=True)
|
|
121
|
+
self._atomic_write_yaml(self.cache_file, hosts_data)
|
|
111
122
|
|
|
112
123
|
# Save metadata
|
|
113
124
|
cache_metadata = {
|
|
@@ -117,8 +128,7 @@ class HostCache:
|
|
|
117
128
|
'cache_version': '1.0'
|
|
118
129
|
}
|
|
119
130
|
|
|
120
|
-
|
|
121
|
-
yaml.dump(cache_metadata, f, default_flow_style=False, sort_keys=True)
|
|
131
|
+
self._atomic_write_yaml(self.metadata_file, cache_metadata)
|
|
122
132
|
|
|
123
133
|
self.logger.info(f"Successfully cached {len(hosts)} hosts to {self.cache_file}")
|
|
124
134
|
return True
|
|
@@ -155,12 +165,8 @@ class HostCache:
|
|
|
155
165
|
if 'name' not in host_dict or 'ip' not in host_dict:
|
|
156
166
|
self.logger.warning(f"Skipping host with missing required fields: {host_dict}")
|
|
157
167
|
continue
|
|
158
|
-
|
|
159
|
-
host = Host(
|
|
160
|
-
name=host_dict['name'],
|
|
161
|
-
ip=host_dict['ip'],
|
|
162
|
-
**host_dict.get('metadata', {})
|
|
163
|
-
)
|
|
168
|
+
|
|
169
|
+
host = Host.from_dict(host_dict)
|
|
164
170
|
hosts.append(host)
|
|
165
171
|
|
|
166
172
|
self.logger.info(f"Successfully loaded {len(hosts)} hosts from cache")
|
|
@@ -5,10 +5,27 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Any, Dict, List, Optional
|
|
6
6
|
|
|
7
7
|
import yaml
|
|
8
|
-
from pydantic import BaseModel, Field
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
9
|
|
|
10
10
|
from .. import __version__
|
|
11
11
|
|
|
12
|
+
SUPPORTED_SOT_PROVIDER_TYPES = ("static", "netbox", "ansible", "consul", "git")
|
|
13
|
+
SOT_PROVIDER_LABELS = {
|
|
14
|
+
"static": "Static",
|
|
15
|
+
"netbox": "NetBox",
|
|
16
|
+
"ansible": "Ansible",
|
|
17
|
+
"consul": "Consul",
|
|
18
|
+
"git": "Git",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SUPPORTED_MUX_BACKENDS = ("tmux", "iterm2-native")
|
|
22
|
+
MUX_BACKEND_LABELS = {
|
|
23
|
+
"tmux": "tmux",
|
|
24
|
+
"iterm2-native": "iTerm2 Native",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
SUPPORTED_GIT_INVENTORY_FORMATS = ("static", "ansible")
|
|
28
|
+
|
|
12
29
|
|
|
13
30
|
class SSHplexConfig(BaseModel):
|
|
14
31
|
"""SSHplex main configuration."""
|
|
@@ -16,15 +33,6 @@ class SSHplexConfig(BaseModel):
|
|
|
16
33
|
session_prefix: str = "sshplex"
|
|
17
34
|
|
|
18
35
|
|
|
19
|
-
class NetBoxConfig(BaseModel):
|
|
20
|
-
"""NetBox connection configuration."""
|
|
21
|
-
url: str = Field(..., description="NetBox instance URL")
|
|
22
|
-
token: str = Field(..., description="NetBox API token")
|
|
23
|
-
verify_ssl: bool = True
|
|
24
|
-
timeout: int = 30
|
|
25
|
-
default_filters: Dict[str, str] = Field(default_factory=dict)
|
|
26
|
-
|
|
27
|
-
|
|
28
36
|
class LoggingConfig(BaseModel):
|
|
29
37
|
"""Logging configuration."""
|
|
30
38
|
enabled: bool = True
|
|
@@ -42,7 +50,7 @@ class UIConfig(BaseModel):
|
|
|
42
50
|
class Proxy(BaseModel):
|
|
43
51
|
"""ImportProxies configuration with defaults."""
|
|
44
52
|
name: str = Field("", description="Proxy name")
|
|
45
|
-
imports: list = Field(
|
|
53
|
+
imports: list = Field(default_factory=list, description="List of imports that will use this proxy")
|
|
46
54
|
host: str = Field("", description="Proxy host or ip")
|
|
47
55
|
username: str = Field("", description="Proxy username")
|
|
48
56
|
key_path: str = Field("", description="Proxy key")
|
|
@@ -109,6 +117,16 @@ class TmuxConfig(BaseModel):
|
|
|
109
117
|
description="Split pattern for iTerm2 native: alternate, vertical, horizontal"
|
|
110
118
|
)
|
|
111
119
|
|
|
120
|
+
@field_validator("backend")
|
|
121
|
+
@classmethod
|
|
122
|
+
def validate_backend_value(cls, value: str) -> str:
|
|
123
|
+
normalized = str(value or "").strip().lower()
|
|
124
|
+
if normalized not in SUPPORTED_MUX_BACKENDS:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Invalid backend: {value}. Must be one of: {list(SUPPORTED_MUX_BACKENDS)}"
|
|
127
|
+
)
|
|
128
|
+
return normalized
|
|
129
|
+
|
|
112
130
|
def validate_backend_config(self) -> bool:
|
|
113
131
|
"""Validate backend configuration.
|
|
114
132
|
|
|
@@ -118,10 +136,9 @@ class TmuxConfig(BaseModel):
|
|
|
118
136
|
import platform
|
|
119
137
|
|
|
120
138
|
# Validate backend option
|
|
121
|
-
|
|
122
|
-
if self.backend not in valid_backends:
|
|
139
|
+
if self.backend not in SUPPORTED_MUX_BACKENDS:
|
|
123
140
|
raise ValueError(
|
|
124
|
-
f"Invalid backend: {self.backend}. Must be one of: {
|
|
141
|
+
f"Invalid backend: {self.backend}. Must be one of: {list(SUPPORTED_MUX_BACKENDS)}"
|
|
125
142
|
)
|
|
126
143
|
|
|
127
144
|
# Validate iTerm2 native mode on macOS only
|
|
@@ -146,11 +163,6 @@ class TmuxConfig(BaseModel):
|
|
|
146
163
|
return True
|
|
147
164
|
|
|
148
165
|
|
|
149
|
-
class AnsibleConfig(BaseModel):
|
|
150
|
-
"""Ansible inventory configuration."""
|
|
151
|
-
inventory_paths: List[str] = Field(default_factory=list, description="List of paths to Ansible inventory YAML files")
|
|
152
|
-
default_filters: Dict[str, Any] = Field(default_factory=dict)
|
|
153
|
-
|
|
154
166
|
class ConsulConfig(BaseModel):
|
|
155
167
|
"""Consul-specific configuration with defaults."""
|
|
156
168
|
host: str = Field("consul.example.com", description="Consul host address")
|
|
@@ -164,7 +176,7 @@ class ConsulConfig(BaseModel):
|
|
|
164
176
|
class SoTImportConfig(BaseModel):
|
|
165
177
|
"""Individual SoT import configuration."""
|
|
166
178
|
name: str = Field(..., description="Unique name for this import")
|
|
167
|
-
type: str = Field(..., description="Provider type:
|
|
179
|
+
type: str = Field(..., description=f"Provider type: {', '.join(SUPPORTED_SOT_PROVIDER_TYPES)}")
|
|
168
180
|
|
|
169
181
|
# Static provider fields
|
|
170
182
|
hosts: Optional[List[Dict[str, Any]]] = None
|
|
@@ -185,23 +197,72 @@ class SoTImportConfig(BaseModel):
|
|
|
185
197
|
# Git provider fields
|
|
186
198
|
repo_url: Optional[str] = None
|
|
187
199
|
branch: Optional[str] = "main"
|
|
188
|
-
source_pattern: Optional[str] =
|
|
189
|
-
path: Optional[str] = "hosts"
|
|
190
|
-
file_glob: Optional[str] = "**/*.y*ml"
|
|
200
|
+
source_pattern: Optional[str] = "hosts/**/*.y*ml"
|
|
191
201
|
auto_pull: Optional[bool] = True
|
|
192
202
|
pull_interval_seconds: Optional[int] = 300
|
|
193
203
|
priority: Optional[int] = 100
|
|
194
204
|
pull_strategy: Optional[str] = "ff-only"
|
|
195
205
|
inventory_format: Optional[str] = "static"
|
|
196
206
|
|
|
207
|
+
@field_validator("type")
|
|
208
|
+
@classmethod
|
|
209
|
+
def validate_provider_type(cls, value: str) -> str:
|
|
210
|
+
normalized = str(value or "").strip().lower()
|
|
211
|
+
if normalized not in SUPPORTED_SOT_PROVIDER_TYPES:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"Unsupported provider type '{value}'. "
|
|
214
|
+
f"Supported values: {list(SUPPORTED_SOT_PROVIDER_TYPES)}"
|
|
215
|
+
)
|
|
216
|
+
return normalized
|
|
217
|
+
|
|
218
|
+
@field_validator("pull_strategy")
|
|
219
|
+
@classmethod
|
|
220
|
+
def validate_pull_strategy(cls, value: Optional[str]) -> Optional[str]:
|
|
221
|
+
if value is None:
|
|
222
|
+
return value
|
|
223
|
+
normalized = str(value).strip().lower()
|
|
224
|
+
if normalized and normalized != "ff-only":
|
|
225
|
+
raise ValueError("git pull_strategy only supports 'ff-only'")
|
|
226
|
+
return normalized or "ff-only"
|
|
227
|
+
|
|
228
|
+
@field_validator("inventory_format")
|
|
229
|
+
@classmethod
|
|
230
|
+
def validate_inventory_format(cls, value: Optional[str]) -> Optional[str]:
|
|
231
|
+
if value is None:
|
|
232
|
+
return value
|
|
233
|
+
normalized = str(value).strip().lower()
|
|
234
|
+
if normalized not in SUPPORTED_GIT_INVENTORY_FORMATS:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Unsupported git inventory format '{value}'. "
|
|
237
|
+
f"Supported values: {list(SUPPORTED_GIT_INVENTORY_FORMATS)}"
|
|
238
|
+
)
|
|
239
|
+
return normalized
|
|
240
|
+
|
|
197
241
|
class SoTConfig(BaseModel):
|
|
198
242
|
"""Source of Truth configuration."""
|
|
199
243
|
providers: List[str] = Field(
|
|
200
|
-
default_factory=
|
|
244
|
+
default_factory=list,
|
|
201
245
|
description="List of SoT providers to use: static, netbox, ansible, consul, git",
|
|
202
246
|
)
|
|
203
247
|
import_: List[SoTImportConfig] = Field(alias='import', default_factory=list, description="List of import configurations")
|
|
204
248
|
|
|
249
|
+
@field_validator("providers")
|
|
250
|
+
@classmethod
|
|
251
|
+
def validate_enabled_providers(cls, value: List[str]) -> List[str]:
|
|
252
|
+
normalized: List[str] = []
|
|
253
|
+
for provider in value or []:
|
|
254
|
+
provider_name = str(provider or "").strip().lower()
|
|
255
|
+
if not provider_name:
|
|
256
|
+
continue
|
|
257
|
+
if provider_name not in SUPPORTED_SOT_PROVIDER_TYPES:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"Unsupported provider '{provider_name}'. "
|
|
260
|
+
f"Supported values: {list(SUPPORTED_SOT_PROVIDER_TYPES)}"
|
|
261
|
+
)
|
|
262
|
+
if provider_name not in normalized:
|
|
263
|
+
normalized.append(provider_name)
|
|
264
|
+
return normalized
|
|
265
|
+
|
|
205
266
|
|
|
206
267
|
class CacheConfig(BaseModel):
|
|
207
268
|
"""Host cache configuration."""
|
|
@@ -214,8 +275,6 @@ class Config(BaseModel):
|
|
|
214
275
|
"""Main SSHplex configuration model."""
|
|
215
276
|
sshplex: SSHplexConfig = Field(default_factory=SSHplexConfig)
|
|
216
277
|
sot: SoTConfig = Field(default_factory=SoTConfig)
|
|
217
|
-
netbox: Optional[NetBoxConfig] = None
|
|
218
|
-
ansible_inventory: Optional[AnsibleConfig] = None
|
|
219
278
|
ssh: SSHConfig = Field(default_factory=SSHConfig)
|
|
220
279
|
tmux: TmuxConfig = Field(default_factory=TmuxConfig)
|
|
221
280
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
@@ -14,7 +14,11 @@ from rich.prompt import Confirm, Prompt
|
|
|
14
14
|
from rich.table import Table
|
|
15
15
|
from rich.text import Text
|
|
16
16
|
|
|
17
|
-
from ..config import
|
|
17
|
+
from ..config import (
|
|
18
|
+
SUPPORTED_MUX_BACKENDS,
|
|
19
|
+
SUPPORTED_SOT_PROVIDER_TYPES,
|
|
20
|
+
Config,
|
|
21
|
+
)
|
|
18
22
|
from ..logger import get_logger
|
|
19
23
|
|
|
20
24
|
|
|
@@ -181,14 +185,18 @@ class OnboardingWizard:
|
|
|
181
185
|
Provider configuration dict or None if cancelled
|
|
182
186
|
"""
|
|
183
187
|
self.console.print("\n" + "─" * 60)
|
|
184
|
-
|
|
188
|
+
|
|
185
189
|
# Provider type selection
|
|
190
|
+
provider_descriptions = {
|
|
191
|
+
"static": "Static host list (manual entry)",
|
|
192
|
+
"netbox": "NetBox (infrastructure source of truth)",
|
|
193
|
+
"ansible": "Ansible inventory file",
|
|
194
|
+
"consul": "HashiCorp Consul (service discovery)",
|
|
195
|
+
"git": "Git repository inventory (static or ansible YAML)",
|
|
196
|
+
}
|
|
186
197
|
provider_types = [
|
|
187
|
-
(
|
|
188
|
-
|
|
189
|
-
("ansible", "Ansible inventory file"),
|
|
190
|
-
("consul", "HashiCorp Consul (service discovery)"),
|
|
191
|
-
("git", "Git repository inventory (static or ansible YAML)"),
|
|
198
|
+
(provider_type, provider_descriptions.get(provider_type, provider_type))
|
|
199
|
+
for provider_type in SUPPORTED_SOT_PROVIDER_TYPES
|
|
192
200
|
]
|
|
193
201
|
|
|
194
202
|
self.console.print("\n[bold]Select inventory source type:[/bold]")
|
|
@@ -197,20 +205,19 @@ class OnboardingWizard:
|
|
|
197
205
|
|
|
198
206
|
choice = Prompt.ask("\nChoice", choices=[str(i) for i in range(1, len(provider_types) + 1)], default="1")
|
|
199
207
|
provider_type = provider_types[int(choice) - 1][0]
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
return None
|
|
208
|
+
|
|
209
|
+
configurators = {
|
|
210
|
+
"static": self._configure_static,
|
|
211
|
+
"netbox": self._configure_netbox,
|
|
212
|
+
"ansible": self._configure_ansible,
|
|
213
|
+
"consul": self._configure_consul,
|
|
214
|
+
"git": self._configure_git,
|
|
215
|
+
}
|
|
216
|
+
handler = configurators.get(provider_type)
|
|
217
|
+
if handler is None:
|
|
218
|
+
self.console.print(f"[red]Unsupported provider type: {provider_type}[/red]")
|
|
219
|
+
return None
|
|
220
|
+
return handler()
|
|
214
221
|
|
|
215
222
|
def _configure_static(self) -> Optional[Dict[str, Any]]:
|
|
216
223
|
"""Configure static host provider."""
|
|
@@ -414,16 +421,12 @@ class OnboardingWizard:
|
|
|
414
421
|
except ValueError:
|
|
415
422
|
self.console.print(f"[red]Invalid interval: {interval_input}[/red]")
|
|
416
423
|
|
|
417
|
-
path, file_glob = self._split_source_pattern_legacy(source_pattern)
|
|
418
|
-
|
|
419
424
|
config: Dict[str, Any] = {
|
|
420
425
|
"name": name,
|
|
421
426
|
"type": "git",
|
|
422
427
|
"repo_url": repo_url,
|
|
423
428
|
"branch": branch,
|
|
424
429
|
"source_pattern": source_pattern,
|
|
425
|
-
"path": path,
|
|
426
|
-
"file_glob": file_glob,
|
|
427
430
|
"inventory_format": inventory_format,
|
|
428
431
|
"priority": priority,
|
|
429
432
|
"auto_pull": auto_pull,
|
|
@@ -451,30 +454,6 @@ class OnboardingWizard:
|
|
|
451
454
|
|
|
452
455
|
return config
|
|
453
456
|
|
|
454
|
-
@staticmethod
|
|
455
|
-
def _split_source_pattern_legacy(source_pattern: str) -> tuple[str, str]:
|
|
456
|
-
"""Split source pattern into path/glob compatibility fields."""
|
|
457
|
-
normalized = str(source_pattern or "").strip().lstrip("/")
|
|
458
|
-
if not normalized:
|
|
459
|
-
return "hosts", "**/*.y*ml"
|
|
460
|
-
|
|
461
|
-
if normalized.endswith((".yml", ".yaml")):
|
|
462
|
-
return normalized, "**/*.y*ml"
|
|
463
|
-
|
|
464
|
-
wildcard_chars = {"*", "?", "["}
|
|
465
|
-
parts = normalized.split("/")
|
|
466
|
-
wildcard_index = -1
|
|
467
|
-
for idx, part in enumerate(parts):
|
|
468
|
-
if any(char in part for char in wildcard_chars):
|
|
469
|
-
wildcard_index = idx
|
|
470
|
-
break
|
|
471
|
-
|
|
472
|
-
if wildcard_index == -1:
|
|
473
|
-
return normalized, "**/*.y*ml"
|
|
474
|
-
if wildcard_index == 0:
|
|
475
|
-
return ".", normalized
|
|
476
|
-
return "/".join(parts[:wildcard_index]), "/".join(parts[wildcard_index:])
|
|
477
|
-
|
|
478
457
|
def _test_netbox_connection(self, config: Dict[str, Any]) -> bool:
|
|
479
458
|
"""Test NetBox connection."""
|
|
480
459
|
self.logger.info(f"Testing NetBox connection to {config['url']}")
|
|
@@ -583,7 +562,7 @@ class OnboardingWizard:
|
|
|
583
562
|
default_backend = "tmux" if tmux_installed else "iterm2-native"
|
|
584
563
|
backend = Prompt.ask(
|
|
585
564
|
"Backend",
|
|
586
|
-
choices=
|
|
565
|
+
choices=list(SUPPORTED_MUX_BACKENDS),
|
|
587
566
|
default=default_backend,
|
|
588
567
|
)
|
|
589
568
|
return backend
|
|
@@ -684,7 +663,7 @@ class OnboardingWizard:
|
|
|
684
663
|
cfg = provider.get("config", {}) or {}
|
|
685
664
|
details = f"{cfg.get('scheme', 'http')}://{cfg.get('host', 'localhost')}:{cfg.get('port', 8500)}"
|
|
686
665
|
elif provider_type == "git":
|
|
687
|
-
details = str(provider.get("source_pattern",
|
|
666
|
+
details = str(provider.get("source_pattern", ""))
|
|
688
667
|
|
|
689
668
|
provider_table.add_row(provider_name, provider_type, details)
|
|
690
669
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Base classes for Source of Truth providers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Host:
|
|
8
|
+
"""Simple host data structure."""
|
|
9
|
+
|
|
10
|
+
_RESERVED_METADATA_KEYS = {"name", "ip", "metadata"}
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
name: str,
|
|
15
|
+
ip: str,
|
|
16
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
17
|
+
**kwargs: Any,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.name = name
|
|
20
|
+
self.ip = ip
|
|
21
|
+
self.metadata: Dict[str, Any] = {}
|
|
22
|
+
|
|
23
|
+
if metadata:
|
|
24
|
+
self.update_metadata(metadata)
|
|
25
|
+
if kwargs:
|
|
26
|
+
self.update_metadata(kwargs)
|
|
27
|
+
|
|
28
|
+
def update_metadata(self, values: Dict[str, Any]) -> None:
|
|
29
|
+
"""Merge metadata values and mirror them as instance attributes."""
|
|
30
|
+
for raw_key, value in values.items():
|
|
31
|
+
key = str(raw_key)
|
|
32
|
+
if key in self._RESERVED_METADATA_KEYS:
|
|
33
|
+
continue
|
|
34
|
+
self.metadata[key] = value
|
|
35
|
+
setattr(self, key, value)
|
|
36
|
+
|
|
37
|
+
def merge_metadata(self, values: Dict[str, Any]) -> None:
|
|
38
|
+
"""Compatibility helper to merge metadata values in-place."""
|
|
39
|
+
self.update_metadata(values)
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
42
|
+
"""Serialize host object to cache-friendly dictionary."""
|
|
43
|
+
return {
|
|
44
|
+
"name": self.name,
|
|
45
|
+
"ip": self.ip,
|
|
46
|
+
"metadata": dict(self.metadata),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, payload: Dict[str, Any]) -> "Host":
|
|
51
|
+
"""Create host object from serialized dictionary payload."""
|
|
52
|
+
metadata = payload.get("metadata", {})
|
|
53
|
+
if not isinstance(metadata, dict):
|
|
54
|
+
metadata = {}
|
|
55
|
+
|
|
56
|
+
extras = {
|
|
57
|
+
key: value
|
|
58
|
+
for key, value in payload.items()
|
|
59
|
+
if key not in cls._RESERVED_METADATA_KEYS
|
|
60
|
+
}
|
|
61
|
+
merged_metadata = dict(metadata)
|
|
62
|
+
merged_metadata.update(extras)
|
|
63
|
+
|
|
64
|
+
return cls(
|
|
65
|
+
name=str(payload["name"]),
|
|
66
|
+
ip=str(payload["ip"]),
|
|
67
|
+
metadata=merged_metadata,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
return f"{self.name} ({self.ip})"
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return f"Host(name='{self.name}', ip='{self.ip}')"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SoTProvider(ABC):
|
|
78
|
+
"""Abstract base class for Source of Truth providers."""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def connect(self) -> bool:
|
|
82
|
+
"""Establish connection to the SoT provider.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if connection successful, False otherwise
|
|
86
|
+
"""
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def get_hosts(self, filters: Optional[Dict[str, Any]] = None) -> List[Host]:
|
|
91
|
+
"""Retrieve hosts from the SoT provider.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
filters: Optional filters to apply
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of Host objects
|
|
98
|
+
"""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def test_connection(self) -> bool:
|
|
103
|
+
"""Test connection to the SoT provider.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True if connection is healthy, False otherwise
|
|
107
|
+
"""
|
|
108
|
+
pass
|