sshplex 1.6.4__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.
Files changed (51) hide show
  1. {sshplex-1.6.4/sshplex.egg-info → sshplex-1.8.0}/PKG-INFO +7 -5
  2. {sshplex-1.6.4 → sshplex-1.8.0}/README.md +4 -1
  3. {sshplex-1.6.4 → sshplex-1.8.0}/pyproject.toml +3 -4
  4. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/__init__.py +1 -1
  5. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/cli.py +8 -0
  6. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/config-template.yaml +23 -1
  7. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/cache.py +29 -23
  8. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/config.py +97 -23
  9. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/onboarding/wizard.py +250 -21
  10. sshplex-1.8.0/sshplex/lib/sot/base.py +108 -0
  11. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/sot/factory.py +189 -66
  12. sshplex-1.8.0/sshplex/lib/sot/git.py +517 -0
  13. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/sot/netbox.py +17 -2
  14. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/ui/config_editor.py +634 -95
  15. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/ui/host_selector.py +85 -11
  16. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/ui/session_manager.py +48 -48
  17. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/sshplex_connector.py +46 -21
  18. {sshplex-1.6.4 → sshplex-1.8.0/sshplex.egg-info}/PKG-INFO +7 -5
  19. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex.egg-info/SOURCES.txt +4 -1
  20. {sshplex-1.6.4 → sshplex-1.8.0}/tests/test_cache.py +7 -0
  21. {sshplex-1.6.4 → sshplex-1.8.0}/tests/test_config.py +21 -0
  22. sshplex-1.8.0/tests/test_sshplex_connector.py +84 -0
  23. sshplex-1.8.0/tests/test_ui_config_editor_columns.py +35 -0
  24. sshplex-1.6.4/sshplex/lib/sot/base.py +0 -57
  25. {sshplex-1.6.4 → sshplex-1.8.0}/LICENSE +0 -0
  26. {sshplex-1.6.4 → sshplex-1.8.0}/MANIFEST.in +0 -0
  27. {sshplex-1.6.4 → sshplex-1.8.0}/setup.cfg +0 -0
  28. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/__init__.py +0 -0
  29. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/commands.py +0 -0
  30. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/logger.py +0 -0
  31. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/multiplexer/__init__.py +0 -0
  32. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/multiplexer/base.py +0 -0
  33. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/multiplexer/iterm2_native.py +0 -0
  34. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/multiplexer/tmux.py +0 -0
  35. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/onboarding/__init__.py +0 -0
  36. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/sot/__init__.py +0 -0
  37. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/sot/ansible.py +0 -0
  38. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/sot/consul.py +0 -0
  39. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/sot/static.py +0 -0
  40. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/ui/__init__.py +0 -0
  41. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/utils/__init__.py +0 -0
  42. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/utils/iterm2.py +0 -0
  43. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/lib/utils/ssh_config.py +0 -0
  44. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex/main.py +0 -0
  45. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex.egg-info/dependency_links.txt +0 -0
  46. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex.egg-info/entry_points.txt +0 -0
  47. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex.egg-info/requires.txt +0 -0
  48. {sshplex-1.6.4 → sshplex-1.8.0}/sshplex.egg-info/top_level.txt +0 -0
  49. {sshplex-1.6.4 → sshplex-1.8.0}/tests/test_commands.py +0 -0
  50. {sshplex-1.6.4 → sshplex-1.8.0}/tests/test_iterm2_session_manager.py +0 -0
  51. {sshplex-1.6.4 → sshplex-1.8.0}/tests/test_main.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshplex
3
- Version: 1.6.4
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.8
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.8+
92
+ - Python 3.10+
94
93
  - tmux (Linux/macOS) and/or iTerm2 (macOS)
95
94
  - SSH key configured for target hosts
96
95
 
@@ -110,9 +109,12 @@ sshplex
110
109
  | **NetBox** | `netbox` | None (included in base install) | Inventory-driven infrastructure with metadata |
111
110
  | **Ansible** | `ansible` | None | Reusing existing Ansible inventory files |
112
111
  | **Consul** | `consul` | `pip install "sshplex[consul]"` | Service discovery and dynamic node catalogs |
112
+ | **Git** | `git` | `git` binary in PATH | Git-backed inventories with auto-pull (`static` or `ansible` YAML) |
113
113
 
114
114
  Provider activation is controlled by `sot.providers`, and each source is configured as an item in `sot.import`.
115
115
 
116
+ Use multiple `git` imports and tune `priority` for deterministic overrides.
117
+
116
118
 
117
119
  ## Local Demo (Consul + Ansible)
118
120
 
@@ -37,7 +37,7 @@ sshplex
37
37
 
38
38
  ### Prerequisites
39
39
 
40
- - Python 3.8+
40
+ - Python 3.10+
41
41
  - tmux (Linux/macOS) and/or iTerm2 (macOS)
42
42
  - SSH key configured for target hosts
43
43
 
@@ -57,9 +57,12 @@ sshplex
57
57
  | **NetBox** | `netbox` | None (included in base install) | Inventory-driven infrastructure with metadata |
58
58
  | **Ansible** | `ansible` | None | Reusing existing Ansible inventory files |
59
59
  | **Consul** | `consul` | `pip install "sshplex[consul]"` | Service discovery and dynamic node catalogs |
60
+ | **Git** | `git` | `git` binary in PATH | Git-backed inventories with auto-pull (`static` or `ansible` YAML) |
60
61
 
61
62
  Provider activation is controlled by `sot.providers`, and each source is configured as an item in `sot.import`.
62
63
 
64
+ Use multiple `git` imports and tune `priority` for deterministic overrides.
65
+
63
66
 
64
67
  ## Local Demo (Consul + Ansible)
65
68
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sshplex"
7
- version = "1.6.4"
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.8"
30
+ requires-python = ">=3.10"
32
31
  dependencies = [
33
32
  "pynetbox==7.6.1",
34
33
  "textual==8.0.0",
@@ -1,4 +1,4 @@
1
1
  """SSHplex - SSH Connection Multiplexer"""
2
- __version__ = "1.6.4"
2
+ __version__ = "1.8.0"
3
3
  __author__ = "MJAHED Sabri"
4
4
  __email__ = "contact@sabrimjahed.com"
@@ -127,6 +127,8 @@ def list_providers(config: Any, logger: Any) -> int:
127
127
  status_icon = "📝"
128
128
  elif provider.type == "consul":
129
129
  status_icon = "🔍"
130
+ elif provider.type == "git":
131
+ status_icon = "🔄"
130
132
 
131
133
  print(f"{i}. {status_icon} {provider.name}")
132
134
  print(f" Type: {provider.type}")
@@ -137,6 +139,12 @@ def list_providers(config: Any, logger: Any) -> int:
137
139
  print(f" Paths: {', '.join(provider.inventory_paths)}")
138
140
  elif provider.type == "consul" and provider.config:
139
141
  print(f" Host: {provider.config.host}:{provider.config.port}")
142
+ elif provider.type == "git" and provider.repo_url:
143
+ branch = provider.branch or "main"
144
+ inventory_format = provider.inventory_format or "static"
145
+ print(f" Repo: {provider.repo_url} [{branch}, {inventory_format}]")
146
+ source_pattern = provider.source_pattern or "hosts/**/*.y*ml"
147
+ print(f" Source: {source_pattern}")
140
148
  elif provider.type == "static" and provider.hosts:
141
149
  print(f" Hosts: {len(provider.hosts)} defined")
142
150
 
@@ -4,7 +4,7 @@ sshplex:
4
4
 
5
5
  # Source of Truth configuration
6
6
  sot:
7
- providers: ["static", "netbox", "ansible", "consul"] # Available: static, netbox, ansible, consul
7
+ providers: ["static", "netbox", "ansible", "consul", "git"] # Available: static, netbox, ansible, consul, git
8
8
  import:
9
9
  - name: "production-servers"
10
10
  type: static
@@ -71,6 +71,28 @@ sot:
71
71
  verify: false
72
72
  dc: "lisbon"
73
73
  cert: "" # Path to SSL certificate (optional)
74
+ - name: "personal-git-hosts"
75
+ type: git
76
+ repo_url: "git@github.com:your-user/sshplex-hosts.git"
77
+ branch: "main"
78
+ source_pattern: "hosts/**/*.y*ml"
79
+ inventory_format: "static" # static, ansible
80
+ auto_pull: true
81
+ pull_interval_seconds: 300
82
+ priority: 100
83
+ pull_strategy: "ff-only"
84
+ - name: "git-ansible-inventory"
85
+ type: git
86
+ repo_url: "git@github.com:your-org/ansible-inventory.git"
87
+ branch: "main"
88
+ source_pattern: "inventory/**/*.y*ml"
89
+ inventory_format: "ansible"
90
+ default_filters:
91
+ groups: ["webservers", "databases"]
92
+ auto_pull: true
93
+ pull_interval_seconds: 300
94
+ priority: 50
95
+ pull_strategy: "ff-only"
74
96
 
75
97
  ssh:
76
98
  username: "admin"
@@ -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 = os.path.expanduser("~/.cache/sshplex")
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
- # Prepare hosts data for serialization
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
- with open(self.cache_file, 'w') as f:
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
- with open(self.metadata_file, 'w') as f:
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([], description="List of imports that will use this proxy")
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
- valid_backends = ["tmux", "iterm2-native"]
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: {valid_backends}"
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: static, netbox, ansible, consul")
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
@@ -182,11 +194,75 @@ class SoTImportConfig(BaseModel):
182
194
  # Consul provider fields
183
195
  config: Optional[ConsulConfig] = None
184
196
 
197
+ # Git provider fields
198
+ repo_url: Optional[str] = None
199
+ branch: Optional[str] = "main"
200
+ source_pattern: Optional[str] = "hosts/**/*.y*ml"
201
+ auto_pull: Optional[bool] = True
202
+ pull_interval_seconds: Optional[int] = 300
203
+ priority: Optional[int] = 100
204
+ pull_strategy: Optional[str] = "ff-only"
205
+ inventory_format: Optional[str] = "static"
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
+
185
241
  class SoTConfig(BaseModel):
186
242
  """Source of Truth configuration."""
187
- providers: List[str] = Field(default_factory=lambda: ["static"], description="List of SoT providers to use: static, netbox, ansible")
243
+ providers: List[str] = Field(
244
+ default_factory=list,
245
+ description="List of SoT providers to use: static, netbox, ansible, consul, git",
246
+ )
188
247
  import_: List[SoTImportConfig] = Field(alias='import', default_factory=list, description="List of import configurations")
189
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
+
190
266
 
191
267
  class CacheConfig(BaseModel):
192
268
  """Host cache configuration."""
@@ -199,8 +275,6 @@ class Config(BaseModel):
199
275
  """Main SSHplex configuration model."""
200
276
  sshplex: SSHplexConfig = Field(default_factory=SSHplexConfig)
201
277
  sot: SoTConfig = Field(default_factory=SoTConfig)
202
- netbox: Optional[NetBoxConfig] = None
203
- ansible_inventory: Optional[AnsibleConfig] = None
204
278
  ssh: SSHConfig = Field(default_factory=SSHConfig)
205
279
  tmux: TmuxConfig = Field(default_factory=TmuxConfig)
206
280
  logging: LoggingConfig = Field(default_factory=LoggingConfig)