sshplex 1.6.4__tar.gz → 1.7.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.6.4/sshplex.egg-info → sshplex-1.7.0}/PKG-INFO +4 -1
- {sshplex-1.6.4 → sshplex-1.7.0}/README.md +3 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/pyproject.toml +1 -1
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/__init__.py +1 -1
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/cli.py +8 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/config-template.yaml +23 -1
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/config.py +17 -2
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/onboarding/wizard.py +252 -2
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/factory.py +85 -0
- sshplex-1.7.0/sshplex/lib/sot/git.py +534 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/config_editor.py +650 -87
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/host_selector.py +69 -6
- {sshplex-1.6.4 → sshplex-1.7.0/sshplex.egg-info}/PKG-INFO +4 -1
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/SOURCES.txt +3 -1
- {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_config.py +23 -0
- sshplex-1.7.0/tests/test_ui_config_editor_columns.py +35 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/LICENSE +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/MANIFEST.in +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/setup.cfg +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/__init__.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/cache.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/commands.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/logger.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/__init__.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/base.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/iterm2_native.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/tmux.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/onboarding/__init__.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/__init__.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/ansible.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/base.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/consul.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/netbox.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/static.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/__init__.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/session_manager.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/utils/__init__.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/utils/iterm2.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/utils/ssh_config.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/main.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/sshplex_connector.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/dependency_links.txt +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/entry_points.txt +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/requires.txt +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/top_level.txt +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_cache.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_commands.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_iterm2_session_manager.py +0 -0
- {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_main.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshplex
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: Multiplex your SSH connections with style
|
|
5
5
|
Author-email: MJAHED Sabri <contact@sabrimjahed.com>
|
|
6
6
|
License: MIT
|
|
@@ -110,9 +110,12 @@ sshplex
|
|
|
110
110
|
| **NetBox** | `netbox` | None (included in base install) | Inventory-driven infrastructure with metadata |
|
|
111
111
|
| **Ansible** | `ansible` | None | Reusing existing Ansible inventory files |
|
|
112
112
|
| **Consul** | `consul` | `pip install "sshplex[consul]"` | Service discovery and dynamic node catalogs |
|
|
113
|
+
| **Git** | `git` | `git` binary in PATH | Git-backed inventories with auto-pull (`static` or `ansible` YAML) |
|
|
113
114
|
|
|
114
115
|
Provider activation is controlled by `sot.providers`, and each source is configured as an item in `sot.import`.
|
|
115
116
|
|
|
117
|
+
Use multiple `git` imports and tune `priority` for deterministic overrides.
|
|
118
|
+
|
|
116
119
|
|
|
117
120
|
## Local Demo (Consul + Ansible)
|
|
118
121
|
|
|
@@ -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.
|
|
7
|
+
version = "1.7.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"
|
|
@@ -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 f"{provider.path}/{provider.file_glob}"
|
|
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"
|
|
@@ -164,7 +164,7 @@ class ConsulConfig(BaseModel):
|
|
|
164
164
|
class SoTImportConfig(BaseModel):
|
|
165
165
|
"""Individual SoT import configuration."""
|
|
166
166
|
name: str = Field(..., description="Unique name for this import")
|
|
167
|
-
type: str = Field(..., description="Provider type: static, netbox, ansible, consul")
|
|
167
|
+
type: str = Field(..., description="Provider type: static, netbox, ansible, consul, git")
|
|
168
168
|
|
|
169
169
|
# Static provider fields
|
|
170
170
|
hosts: Optional[List[Dict[str, Any]]] = None
|
|
@@ -182,9 +182,24 @@ class SoTImportConfig(BaseModel):
|
|
|
182
182
|
# Consul provider fields
|
|
183
183
|
config: Optional[ConsulConfig] = None
|
|
184
184
|
|
|
185
|
+
# Git provider fields
|
|
186
|
+
repo_url: Optional[str] = None
|
|
187
|
+
branch: Optional[str] = "main"
|
|
188
|
+
source_pattern: Optional[str] = None
|
|
189
|
+
path: Optional[str] = "hosts"
|
|
190
|
+
file_glob: Optional[str] = "**/*.y*ml"
|
|
191
|
+
auto_pull: Optional[bool] = True
|
|
192
|
+
pull_interval_seconds: Optional[int] = 300
|
|
193
|
+
priority: Optional[int] = 100
|
|
194
|
+
pull_strategy: Optional[str] = "ff-only"
|
|
195
|
+
inventory_format: Optional[str] = "static"
|
|
196
|
+
|
|
185
197
|
class SoTConfig(BaseModel):
|
|
186
198
|
"""Source of Truth configuration."""
|
|
187
|
-
providers: List[str] = Field(
|
|
199
|
+
providers: List[str] = Field(
|
|
200
|
+
default_factory=lambda: ["static"],
|
|
201
|
+
description="List of SoT providers to use: static, netbox, ansible, consul, git",
|
|
202
|
+
)
|
|
188
203
|
import_: List[SoTImportConfig] = Field(alias='import', default_factory=list, description="List of import configurations")
|
|
189
204
|
|
|
190
205
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Interactive onboarding wizard for SSHplex."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import platform
|
|
4
5
|
import shutil
|
|
6
|
+
import subprocess
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from typing import Any, Dict, List, Optional
|
|
7
9
|
|
|
@@ -62,6 +64,12 @@ class OnboardingWizard:
|
|
|
62
64
|
|
|
63
65
|
# Generate configuration
|
|
64
66
|
config = self._generate_config()
|
|
67
|
+
|
|
68
|
+
# Review before saving
|
|
69
|
+
self._show_configuration_summary(config)
|
|
70
|
+
if not Confirm.ask("\nSave this configuration?", default=True):
|
|
71
|
+
self.console.print("\n[yellow]Onboarding cancelled. Configuration was not saved.[/yellow]")
|
|
72
|
+
return False
|
|
65
73
|
|
|
66
74
|
# Save configuration
|
|
67
75
|
if self._save_config(config):
|
|
@@ -79,7 +87,7 @@ class OnboardingWizard:
|
|
|
79
87
|
welcome_text.append("Let's configure SSHplex for your environment.")
|
|
80
88
|
welcome_text.append("\nThis wizard will help you:")
|
|
81
89
|
welcome_text.append("\n • Detect SSH keys and system dependencies")
|
|
82
|
-
welcome_text.append("\n • Configure inventory sources (NetBox, Ansible, Consul,
|
|
90
|
+
welcome_text.append("\n • Configure inventory sources (Static, NetBox, Ansible, Consul, Git)")
|
|
83
91
|
welcome_text.append("\n • Test connections before saving")
|
|
84
92
|
welcome_text.append("\n • Generate a working configuration file")
|
|
85
93
|
|
|
@@ -90,6 +98,8 @@ class OnboardingWizard:
|
|
|
90
98
|
def _detect_environment(self) -> None:
|
|
91
99
|
"""Detect system environment and dependencies."""
|
|
92
100
|
self.console.print("\n🔍 [bold]Detecting Environment[/bold]\n")
|
|
101
|
+
platform_name = platform.system()
|
|
102
|
+
self.detected_info['platform'] = platform_name
|
|
93
103
|
|
|
94
104
|
# Detect SSH keys
|
|
95
105
|
ssh_dir = Path.home() / ".ssh"
|
|
@@ -106,11 +116,22 @@ class OnboardingWizard:
|
|
|
106
116
|
tmux_path = shutil.which("tmux")
|
|
107
117
|
self.detected_info['tmux_installed'] = tmux_path is not None
|
|
108
118
|
self.detected_info['tmux_path'] = tmux_path
|
|
119
|
+
|
|
120
|
+
# Detect git
|
|
121
|
+
git_path = shutil.which("git")
|
|
122
|
+
self.detected_info['git_installed'] = git_path is not None
|
|
123
|
+
self.detected_info['git_path'] = git_path
|
|
124
|
+
|
|
125
|
+
# Detect iTerm2 app (macOS only)
|
|
126
|
+
iterm_app_exists = platform_name.lower() == "darwin" and Path("/Applications/iTerm.app").exists()
|
|
127
|
+
self.detected_info['iterm2_installed'] = iterm_app_exists
|
|
109
128
|
|
|
110
129
|
# Display detected info
|
|
111
130
|
table = Table(show_header=False, box=None)
|
|
112
131
|
table.add_column("Item", style="cyan")
|
|
113
132
|
table.add_column("Status")
|
|
133
|
+
|
|
134
|
+
table.add_row("Platform", f"✅ {platform_name}")
|
|
114
135
|
|
|
115
136
|
# SSH keys
|
|
116
137
|
if ssh_keys:
|
|
@@ -124,7 +145,20 @@ class OnboardingWizard:
|
|
|
124
145
|
if tmux_path:
|
|
125
146
|
table.add_row("tmux", f"✅ {tmux_path}")
|
|
126
147
|
else:
|
|
127
|
-
table.add_row("tmux", "
|
|
148
|
+
table.add_row("tmux", "⚠️ Not found (required for tmux backend)")
|
|
149
|
+
|
|
150
|
+
# git
|
|
151
|
+
if git_path:
|
|
152
|
+
table.add_row("git", f"✅ {git_path}")
|
|
153
|
+
else:
|
|
154
|
+
table.add_row("git", "⚠️ Not found (required for git inventory source)")
|
|
155
|
+
|
|
156
|
+
# iTerm2
|
|
157
|
+
if platform_name.lower() == "darwin":
|
|
158
|
+
if iterm_app_exists:
|
|
159
|
+
table.add_row("iTerm2", "✅ /Applications/iTerm.app")
|
|
160
|
+
else:
|
|
161
|
+
table.add_row("iTerm2", "ℹ️ Not found (optional, only for native iTerm2 backend)")
|
|
128
162
|
|
|
129
163
|
self.console.print(table)
|
|
130
164
|
|
|
@@ -154,6 +188,7 @@ class OnboardingWizard:
|
|
|
154
188
|
("netbox", "NetBox (infrastructure source of truth)"),
|
|
155
189
|
("ansible", "Ansible inventory file"),
|
|
156
190
|
("consul", "HashiCorp Consul (service discovery)"),
|
|
191
|
+
("git", "Git repository inventory (static or ansible YAML)"),
|
|
157
192
|
]
|
|
158
193
|
|
|
159
194
|
self.console.print("\n[bold]Select inventory source type:[/bold]")
|
|
@@ -172,6 +207,8 @@ class OnboardingWizard:
|
|
|
172
207
|
return self._configure_ansible()
|
|
173
208
|
elif provider_type == "consul":
|
|
174
209
|
return self._configure_consul()
|
|
210
|
+
elif provider_type == "git":
|
|
211
|
+
return self._configure_git()
|
|
175
212
|
|
|
176
213
|
return None
|
|
177
214
|
|
|
@@ -333,6 +370,110 @@ class OnboardingWizard:
|
|
|
333
370
|
return None
|
|
334
371
|
|
|
335
372
|
return config
|
|
373
|
+
|
|
374
|
+
def _configure_git(self) -> Optional[Dict[str, Any]]:
|
|
375
|
+
"""Configure read-only git inventory provider."""
|
|
376
|
+
self.console.print("\n[bold cyan]Git Inventory Configuration[/bold cyan]")
|
|
377
|
+
|
|
378
|
+
if not self.detected_info.get('git_installed'):
|
|
379
|
+
self.console.print("[yellow]⚠️ git was not detected on this machine.[/yellow]")
|
|
380
|
+
if not Confirm.ask("Continue configuring git provider anyway?", default=False):
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
name = Prompt.ask("Provider name", default="git-hosts")
|
|
384
|
+
repo_url = Prompt.ask("Repository URL", default="git@github.com:org/hosts.git")
|
|
385
|
+
branch = Prompt.ask("Branch", default="main")
|
|
386
|
+
source_pattern = Prompt.ask(
|
|
387
|
+
"Source pattern (path + glob)",
|
|
388
|
+
default="hosts/**/*.y*ml",
|
|
389
|
+
)
|
|
390
|
+
inventory_format = Prompt.ask(
|
|
391
|
+
"Inventory format",
|
|
392
|
+
choices=["static", "ansible"],
|
|
393
|
+
default="static",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
while True:
|
|
397
|
+
priority_input = Prompt.ask("Priority", default="100")
|
|
398
|
+
try:
|
|
399
|
+
priority = int(priority_input)
|
|
400
|
+
break
|
|
401
|
+
except ValueError:
|
|
402
|
+
self.console.print(f"[red]Invalid priority: {priority_input}[/red]")
|
|
403
|
+
|
|
404
|
+
auto_pull = Confirm.ask("Enable auto pull", default=True)
|
|
405
|
+
pull_interval_seconds = 300
|
|
406
|
+
if auto_pull:
|
|
407
|
+
while True:
|
|
408
|
+
interval_input = Prompt.ask("Auto pull interval (seconds)", default="300")
|
|
409
|
+
try:
|
|
410
|
+
pull_interval_seconds = int(interval_input)
|
|
411
|
+
if pull_interval_seconds >= 0:
|
|
412
|
+
break
|
|
413
|
+
self.console.print("[red]Interval must be >= 0[/red]")
|
|
414
|
+
except ValueError:
|
|
415
|
+
self.console.print(f"[red]Invalid interval: {interval_input}[/red]")
|
|
416
|
+
|
|
417
|
+
path, file_glob = self._split_source_pattern_legacy(source_pattern)
|
|
418
|
+
|
|
419
|
+
config: Dict[str, Any] = {
|
|
420
|
+
"name": name,
|
|
421
|
+
"type": "git",
|
|
422
|
+
"repo_url": repo_url,
|
|
423
|
+
"branch": branch,
|
|
424
|
+
"source_pattern": source_pattern,
|
|
425
|
+
"path": path,
|
|
426
|
+
"file_glob": file_glob,
|
|
427
|
+
"inventory_format": inventory_format,
|
|
428
|
+
"priority": priority,
|
|
429
|
+
"auto_pull": auto_pull,
|
|
430
|
+
"pull_interval_seconds": pull_interval_seconds,
|
|
431
|
+
"pull_strategy": "ff-only",
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if inventory_format == "ansible":
|
|
435
|
+
groups_str = Prompt.ask(
|
|
436
|
+
"Ansible groups filter (comma-separated, optional)",
|
|
437
|
+
default="",
|
|
438
|
+
)
|
|
439
|
+
if groups_str.strip():
|
|
440
|
+
groups = [group.strip() for group in groups_str.split(",") if group.strip()]
|
|
441
|
+
if groups:
|
|
442
|
+
config["default_filters"] = {"groups": groups}
|
|
443
|
+
|
|
444
|
+
if Confirm.ask("\nTest repository access?", default=True):
|
|
445
|
+
if self._test_git_connection(config):
|
|
446
|
+
self.console.print("✅ Repository access successful!")
|
|
447
|
+
else:
|
|
448
|
+
self.console.print("❌ Could not read remote branch/source. Check URL/auth/branch.")
|
|
449
|
+
if not Confirm.ask("Keep this configuration anyway?", default=False):
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
return config
|
|
453
|
+
|
|
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:])
|
|
336
477
|
|
|
337
478
|
def _test_netbox_connection(self, config: Dict[str, Any]) -> bool:
|
|
338
479
|
"""Test NetBox connection."""
|
|
@@ -393,11 +534,67 @@ class OnboardingWizard:
|
|
|
393
534
|
except Exception as e:
|
|
394
535
|
self.logger.error(f"Consul connection failed: {e}")
|
|
395
536
|
return False
|
|
537
|
+
|
|
538
|
+
def _test_git_connection(self, config: Dict[str, Any]) -> bool:
|
|
539
|
+
"""Test git repository access and branch visibility."""
|
|
540
|
+
repo_url = str(config.get("repo_url", "")).strip()
|
|
541
|
+
branch = str(config.get("branch", "main")).strip() or "main"
|
|
542
|
+
self.logger.info(f"Testing git source access: {repo_url}@{branch}")
|
|
543
|
+
self.console.print("\n🔄 Testing repository access...")
|
|
544
|
+
|
|
545
|
+
if not repo_url:
|
|
546
|
+
self.logger.error("Git repository URL is empty")
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
git_bin = shutil.which("git")
|
|
550
|
+
if not git_bin:
|
|
551
|
+
self.logger.error("git binary not found in PATH")
|
|
552
|
+
return False
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
result = subprocess.run(
|
|
556
|
+
[git_bin, "ls-remote", "--heads", repo_url, branch],
|
|
557
|
+
capture_output=True,
|
|
558
|
+
text=True,
|
|
559
|
+
timeout=20,
|
|
560
|
+
)
|
|
561
|
+
if result.returncode != 0:
|
|
562
|
+
self.logger.error(f"git ls-remote failed: {result.stderr.strip()}")
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
if not (result.stdout or "").strip():
|
|
566
|
+
self.logger.warning(
|
|
567
|
+
f"Repository reachable but no matching branch found for '{branch}'"
|
|
568
|
+
)
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
return True
|
|
572
|
+
except Exception as e:
|
|
573
|
+
self.logger.error(f"Git repository check failed: {e}")
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
def _select_backend(self) -> str:
|
|
577
|
+
"""Choose backend based on detected environment."""
|
|
578
|
+
platform_name = str(self.detected_info.get('platform', platform.system()))
|
|
579
|
+
tmux_installed = bool(self.detected_info.get('tmux_installed', False))
|
|
580
|
+
iterm2_installed = bool(self.detected_info.get('iterm2_installed', False))
|
|
581
|
+
|
|
582
|
+
if platform_name.lower() == "darwin" and iterm2_installed:
|
|
583
|
+
default_backend = "tmux" if tmux_installed else "iterm2-native"
|
|
584
|
+
backend = Prompt.ask(
|
|
585
|
+
"Backend",
|
|
586
|
+
choices=["tmux", "iterm2-native"],
|
|
587
|
+
default=default_backend,
|
|
588
|
+
)
|
|
589
|
+
return backend
|
|
590
|
+
|
|
591
|
+
return "tmux"
|
|
396
592
|
|
|
397
593
|
def _generate_config(self) -> Dict[str, Any]:
|
|
398
594
|
"""Generate configuration dictionary."""
|
|
399
595
|
# Use detected SSH key as default, fallback to common default if none found
|
|
400
596
|
default_key = self.detected_info.get('default_ssh_key') or '~/.ssh/id_ed25519'
|
|
597
|
+
backend = self._select_backend()
|
|
401
598
|
|
|
402
599
|
# Validate SSH port input
|
|
403
600
|
while True:
|
|
@@ -431,6 +628,7 @@ class OnboardingWizard:
|
|
|
431
628
|
"file": "logs/sshplex.log"
|
|
432
629
|
},
|
|
433
630
|
"tmux": {
|
|
631
|
+
"backend": backend,
|
|
434
632
|
"control_with_iterm2": False
|
|
435
633
|
},
|
|
436
634
|
"ui": {
|
|
@@ -438,8 +636,60 @@ class OnboardingWizard:
|
|
|
438
636
|
"table_columns": ["name", "ip", "cluster", "role", "tags", "description", "provider"]
|
|
439
637
|
}
|
|
440
638
|
}
|
|
639
|
+
|
|
640
|
+
if backend == "tmux" and not self.detected_info.get('tmux_installed'):
|
|
641
|
+
self.console.print(
|
|
642
|
+
"\n[yellow]⚠️ tmux backend selected but tmux was not detected. "
|
|
643
|
+
"Install tmux or switch to iTerm2 native backend on macOS.[/yellow]"
|
|
644
|
+
)
|
|
441
645
|
|
|
442
646
|
return config
|
|
647
|
+
|
|
648
|
+
def _show_configuration_summary(self, config: Dict[str, Any]) -> None:
|
|
649
|
+
"""Display final config summary before save."""
|
|
650
|
+
self.console.print("\n🧾 [bold]Configuration Summary[/bold]\n")
|
|
651
|
+
|
|
652
|
+
ssh = config.get("ssh", {})
|
|
653
|
+
tmux = config.get("tmux", {})
|
|
654
|
+
imports = list((config.get("sot", {}) or {}).get("import", []) or [])
|
|
655
|
+
|
|
656
|
+
summary = Table(show_header=False, box=None)
|
|
657
|
+
summary.add_column("Item", style="cyan")
|
|
658
|
+
summary.add_column("Value")
|
|
659
|
+
summary.add_row("Config Path", str(self.config_path))
|
|
660
|
+
summary.add_row("Backend", str(tmux.get("backend", "tmux")))
|
|
661
|
+
summary.add_row("SSH Username", str(ssh.get("username", "")))
|
|
662
|
+
summary.add_row("SSH Port", str(ssh.get("port", 22)))
|
|
663
|
+
summary.add_row("SSH Key", str(ssh.get("key_path", "")))
|
|
664
|
+
summary.add_row("Sources", str(len(imports)))
|
|
665
|
+
self.console.print(summary)
|
|
666
|
+
|
|
667
|
+
if imports:
|
|
668
|
+
provider_table = Table(show_header=True, box=None)
|
|
669
|
+
provider_table.add_column("Name", style="bold")
|
|
670
|
+
provider_table.add_column("Type", style="magenta")
|
|
671
|
+
provider_table.add_column("Details", style="dim")
|
|
672
|
+
|
|
673
|
+
for provider in imports:
|
|
674
|
+
provider_name = str(provider.get("name", "unnamed"))
|
|
675
|
+
provider_type = str(provider.get("type", "unknown"))
|
|
676
|
+
details = ""
|
|
677
|
+
if provider_type == "static":
|
|
678
|
+
details = f"hosts: {len(provider.get('hosts', []) or [])}"
|
|
679
|
+
elif provider_type == "ansible":
|
|
680
|
+
details = f"paths: {len(provider.get('inventory_paths', []) or [])}"
|
|
681
|
+
elif provider_type == "netbox":
|
|
682
|
+
details = str(provider.get("url", ""))
|
|
683
|
+
elif provider_type == "consul":
|
|
684
|
+
cfg = provider.get("config", {}) or {}
|
|
685
|
+
details = f"{cfg.get('scheme', 'http')}://{cfg.get('host', 'localhost')}:{cfg.get('port', 8500)}"
|
|
686
|
+
elif provider_type == "git":
|
|
687
|
+
details = str(provider.get("source_pattern", provider.get("path", "")))
|
|
688
|
+
|
|
689
|
+
provider_table.add_row(provider_name, provider_type, details)
|
|
690
|
+
|
|
691
|
+
self.console.print()
|
|
692
|
+
self.console.print(provider_table)
|
|
443
693
|
|
|
444
694
|
def _save_config(self, config: Dict[str, Any]) -> bool:
|
|
445
695
|
"""Save configuration to file."""
|
|
@@ -4,12 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import time
|
|
6
6
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
9
10
|
from ..cache import HostCache
|
|
10
11
|
from ..logger import get_logger
|
|
11
12
|
from .ansible import AnsibleProvider
|
|
12
13
|
from .base import Host, SoTProvider
|
|
14
|
+
from .git import GitProvider
|
|
13
15
|
from .netbox import NetBoxProvider
|
|
14
16
|
from .static import StaticProvider
|
|
15
17
|
|
|
@@ -112,6 +114,8 @@ class SoTFactory:
|
|
|
112
114
|
provider = self._create_ansible_provider_from_import(import_config)
|
|
113
115
|
elif import_type == "consul":
|
|
114
116
|
provider = self._create_consul_provider(import_config)
|
|
117
|
+
elif import_type == "git":
|
|
118
|
+
provider = self._create_git_provider(import_config)
|
|
115
119
|
else:
|
|
116
120
|
self.logger.error(f"Unknown SoT provider type: {import_type}")
|
|
117
121
|
continue
|
|
@@ -171,6 +175,18 @@ class SoTFactory:
|
|
|
171
175
|
import_config=import_config
|
|
172
176
|
)
|
|
173
177
|
|
|
178
|
+
def _create_git_provider(self, import_config: Any) -> GitProvider | None:
|
|
179
|
+
"""Create Git provider instance from import configuration."""
|
|
180
|
+
repo_url = str(getattr(import_config, "repo_url", "") or "").strip()
|
|
181
|
+
if not repo_url:
|
|
182
|
+
self.logger.error(f"Git provider '{import_config.name}' missing required repo_url")
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
cache_root = Path(getattr(self.config.cache, "cache_dir", "~/.cache/sshplex")).expanduser()
|
|
186
|
+
git_cache_dir = cache_root / "git"
|
|
187
|
+
|
|
188
|
+
return GitProvider(import_config=import_config, cache_dir=str(git_cache_dir))
|
|
189
|
+
|
|
174
190
|
def _create_netbox_provider_from_import(self, import_config: Any) -> NetBoxProvider | None:
|
|
175
191
|
"""Create NetBox provider instance from import configuration.
|
|
176
192
|
|
|
@@ -551,3 +567,72 @@ class SoTFactory:
|
|
|
551
567
|
True if cache is valid, False otherwise
|
|
552
568
|
"""
|
|
553
569
|
return self.cache.is_cache_valid()
|
|
570
|
+
|
|
571
|
+
def sync_git_sources(self, force: bool = False) -> list[dict[str, Any]]:
|
|
572
|
+
"""Sync configured git providers and return per-provider statuses."""
|
|
573
|
+
results: list[dict[str, Any]] = []
|
|
574
|
+
providers_to_sync = [provider for provider in self.providers if isinstance(provider, GitProvider)]
|
|
575
|
+
|
|
576
|
+
if not providers_to_sync:
|
|
577
|
+
providers_to_sync = self._initialize_git_providers_for_sync()
|
|
578
|
+
|
|
579
|
+
for provider in providers_to_sync:
|
|
580
|
+
if not isinstance(provider, GitProvider):
|
|
581
|
+
continue
|
|
582
|
+
try:
|
|
583
|
+
status = provider.sync(force=force)
|
|
584
|
+
results.append(status)
|
|
585
|
+
except Exception as e:
|
|
586
|
+
provider_name = getattr(provider, "provider_name", type(provider).__name__)
|
|
587
|
+
self.logger.error(f"Git sync failed for {provider_name}: {e}")
|
|
588
|
+
results.append(
|
|
589
|
+
{
|
|
590
|
+
"provider": provider_name,
|
|
591
|
+
"status": "error",
|
|
592
|
+
"message": str(e),
|
|
593
|
+
"old_commit": None,
|
|
594
|
+
"new_commit": None,
|
|
595
|
+
"changed_files": 0,
|
|
596
|
+
}
|
|
597
|
+
)
|
|
598
|
+
return results
|
|
599
|
+
|
|
600
|
+
def _initialize_git_providers_for_sync(self) -> list[GitProvider]:
|
|
601
|
+
"""Initialize git providers directly from config for sync-only operations."""
|
|
602
|
+
initialized: list[GitProvider] = []
|
|
603
|
+
configured_imports = list(getattr(self.config.sot, "import_", []) or [])
|
|
604
|
+
if not configured_imports:
|
|
605
|
+
return initialized
|
|
606
|
+
|
|
607
|
+
enabled_provider_types = {
|
|
608
|
+
str(provider_type).strip()
|
|
609
|
+
for provider_type in (getattr(self.config.sot, "providers", []) or [])
|
|
610
|
+
if str(provider_type).strip()
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
providers_explicitly_set = True
|
|
614
|
+
if hasattr(self.config.sot, "model_fields_set"):
|
|
615
|
+
providers_explicitly_set = "providers" in getattr(self.config.sot, "model_fields_set", set())
|
|
616
|
+
|
|
617
|
+
if not providers_explicitly_set:
|
|
618
|
+
enabled_provider_types = {
|
|
619
|
+
str(getattr(import_config, "type", "")).strip()
|
|
620
|
+
for import_config in configured_imports
|
|
621
|
+
if str(getattr(import_config, "type", "")).strip()
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
for import_config in configured_imports:
|
|
625
|
+
import_type = str(getattr(import_config, "type", "")).strip()
|
|
626
|
+
if import_type != "git":
|
|
627
|
+
continue
|
|
628
|
+
|
|
629
|
+
if enabled_provider_types and import_type not in enabled_provider_types:
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
provider = self._create_git_provider(import_config)
|
|
633
|
+
if provider is None:
|
|
634
|
+
continue
|
|
635
|
+
if provider.connect():
|
|
636
|
+
initialized.append(provider)
|
|
637
|
+
|
|
638
|
+
return initialized
|