termainer 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termainer/__init__.py +0 -0
- termainer/app.py +242 -0
- termainer/config.py +82 -0
- termainer/config_manager.py +75 -0
- termainer/locale.py +460 -0
- termainer/providers/__init__.py +0 -0
- termainer/providers/base.py +61 -0
- termainer/providers/docker.py +213 -0
- termainer/providers/kubernetes.py +239 -0
- termainer/providers/openshift.py +77 -0
- termainer/providers/podman.py +158 -0
- termainer/providers/swarm.py +211 -0
- termainer/remote/__init__.py +0 -0
- termainer/remote/ssh.py +157 -0
- termainer/server_manager.py +84 -0
- termainer/ssh_config.py +138 -0
- termainer/ui/__init__.py +0 -0
- termainer/ui/dashboard.py +837 -0
- termainer/ui/environment.py +300 -0
- termainer/ui/home.py +263 -0
- termainer/ui/splash.py +89 -0
- termainer/ui/widgets.py +335 -0
- termainer/utils/__init__.py +0 -0
- termainer/utils/helpers.py +56 -0
- termainer/version.py +1 -0
- termainer-0.4.0.dist-info/METADATA +419 -0
- termainer-0.4.0.dist-info/RECORD +30 -0
- termainer-0.4.0.dist-info/WHEEL +5 -0
- termainer-0.4.0.dist-info/entry_points.txt +2 -0
- termainer-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import AsyncIterator, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from ..remote.ssh import SSHConnection
|
|
10
|
+
from .base import ContainerDetails, ContainerStats, ContainerSummary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SwarmProvider:
|
|
14
|
+
name = "swarm"
|
|
15
|
+
|
|
16
|
+
def __init__(self, ssh: Optional[SSHConnection] = None) -> None:
|
|
17
|
+
self._docker_path: Optional[str] = None
|
|
18
|
+
self._ssh = ssh
|
|
19
|
+
|
|
20
|
+
async def is_available(self) -> bool:
|
|
21
|
+
if self._ssh:
|
|
22
|
+
try:
|
|
23
|
+
state = (await self._ssh.run(["docker", "info", "--format", "{{.Swarm.LocalNodeState}}"])).strip().lower()
|
|
24
|
+
self._docker_path = "docker"
|
|
25
|
+
return state == "active"
|
|
26
|
+
except RuntimeError:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
self._docker_path = shutil.which("docker")
|
|
30
|
+
if not self._docker_path:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
proc = await asyncio.create_subprocess_exec(
|
|
34
|
+
self._docker_path,
|
|
35
|
+
"info",
|
|
36
|
+
"--format",
|
|
37
|
+
"{{.Swarm.LocalNodeState}}",
|
|
38
|
+
stdout=asyncio.subprocess.PIPE,
|
|
39
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
40
|
+
)
|
|
41
|
+
stdout, _ = await proc.communicate()
|
|
42
|
+
if proc.returncode != 0:
|
|
43
|
+
return False
|
|
44
|
+
state = stdout.decode("utf-8", errors="replace").strip().lower()
|
|
45
|
+
return state == "active"
|
|
46
|
+
|
|
47
|
+
async def list_containers(self) -> List[ContainerSummary]:
|
|
48
|
+
raw = await self._run("service", "ls", "--format", "{{json .}}")
|
|
49
|
+
services: List[ContainerSummary] = []
|
|
50
|
+
for line in raw.strip().split("\n"):
|
|
51
|
+
if not line.strip():
|
|
52
|
+
continue
|
|
53
|
+
item = json.loads(line)
|
|
54
|
+
sid = item.get("ID", "")
|
|
55
|
+
name = item.get("Name", "")
|
|
56
|
+
mode = item.get("Mode", "")
|
|
57
|
+
replicas = item.get("Replicas", "")
|
|
58
|
+
image = item.get("Image", "")
|
|
59
|
+
ports = item.get("Ports", "")
|
|
60
|
+
status = f"{mode} {replicas}".strip()
|
|
61
|
+
services.append(
|
|
62
|
+
{
|
|
63
|
+
"id": sid,
|
|
64
|
+
"name": name,
|
|
65
|
+
"image": image,
|
|
66
|
+
"status": status,
|
|
67
|
+
"ports": ports,
|
|
68
|
+
"mode": mode,
|
|
69
|
+
"replicas": replicas,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
return services
|
|
73
|
+
|
|
74
|
+
async def inspect(self, container_id: str) -> ContainerDetails:
|
|
75
|
+
raw = await self._run("service", "inspect", container_id)
|
|
76
|
+
data = json.loads(raw)
|
|
77
|
+
if isinstance(data, list):
|
|
78
|
+
return data[0] if data else {}
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
async def stats(self, container_id: str) -> AsyncIterator[ContainerStats]:
|
|
82
|
+
while True:
|
|
83
|
+
yield {
|
|
84
|
+
"cpu": "N/A",
|
|
85
|
+
"memory": "N/A",
|
|
86
|
+
"net_io": "N/A",
|
|
87
|
+
"pids": "N/A",
|
|
88
|
+
}
|
|
89
|
+
await asyncio.sleep(2)
|
|
90
|
+
|
|
91
|
+
async def logs(
|
|
92
|
+
self, container_id: str, tail: int = 100, follow: bool = False
|
|
93
|
+
) -> AsyncIterator[str]:
|
|
94
|
+
cmd = ["service", "logs", "--raw", "--tail", str(tail)]
|
|
95
|
+
if follow:
|
|
96
|
+
cmd.append("-f")
|
|
97
|
+
cmd.append(container_id)
|
|
98
|
+
|
|
99
|
+
if self._ssh:
|
|
100
|
+
stream = await self._ssh.stream(["docker"] + cmd)
|
|
101
|
+
else:
|
|
102
|
+
proc = await asyncio.create_subprocess_exec(
|
|
103
|
+
self._docker_path,
|
|
104
|
+
*cmd,
|
|
105
|
+
stdout=asyncio.subprocess.PIPE,
|
|
106
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
107
|
+
)
|
|
108
|
+
stream = proc.stdout
|
|
109
|
+
while True:
|
|
110
|
+
line = await stream.readline()
|
|
111
|
+
if not line:
|
|
112
|
+
break
|
|
113
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
114
|
+
if not follow:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
async def get_env(self, container_id: str) -> Dict[str, str]:
|
|
118
|
+
details = await self.inspect(container_id)
|
|
119
|
+
env_list: List[str] = (
|
|
120
|
+
details.get("Spec", {})
|
|
121
|
+
.get("TaskTemplate", {})
|
|
122
|
+
.get("ContainerSpec", {})
|
|
123
|
+
.get("Env", [])
|
|
124
|
+
)
|
|
125
|
+
env_dict: Dict[str, str] = {}
|
|
126
|
+
for entry in env_list:
|
|
127
|
+
if "=" in entry:
|
|
128
|
+
key, _, val = entry.partition("=")
|
|
129
|
+
env_dict[key] = val
|
|
130
|
+
return env_dict
|
|
131
|
+
|
|
132
|
+
async def start(self, container_id: str) -> None:
|
|
133
|
+
await self._run("service", "scale", f"{container_id}=1")
|
|
134
|
+
|
|
135
|
+
async def stop(self, container_id: str) -> None:
|
|
136
|
+
await self._run("service", "scale", f"{container_id}=0")
|
|
137
|
+
|
|
138
|
+
async def restart(self, container_id: str) -> None:
|
|
139
|
+
await self._run("service", "update", "--force", container_id)
|
|
140
|
+
|
|
141
|
+
async def remove(self, container_id: str, force: bool = False) -> None:
|
|
142
|
+
await self._run("service", "rm", container_id)
|
|
143
|
+
|
|
144
|
+
async def set_restart_policy(self, container_id: str, policy: str) -> None:
|
|
145
|
+
# policy: "none" | "on-failure" | "any"
|
|
146
|
+
await self._run("service", "update", f"--restart-condition={policy}", container_id)
|
|
147
|
+
|
|
148
|
+
async def exec_command(self, container_id: str, command: str) -> AsyncIterator[str]:
|
|
149
|
+
# Swarm services don't support exec directly — find a running task container first
|
|
150
|
+
try:
|
|
151
|
+
raw = await self._run(
|
|
152
|
+
"service", "ps", container_id,
|
|
153
|
+
"--filter", "desired-state=running",
|
|
154
|
+
"--format", "{{.ID}}", "--no-trunc",
|
|
155
|
+
)
|
|
156
|
+
task_ids = [t.strip() for t in raw.strip().split("\n") if t.strip()]
|
|
157
|
+
except Exception as e:
|
|
158
|
+
yield f"[error buscando tareas del servicio: {e}]"
|
|
159
|
+
return
|
|
160
|
+
if not task_ids:
|
|
161
|
+
yield "[error: no hay tareas en ejecución para este servicio]"
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
cid_raw = await self._run(
|
|
165
|
+
"inspect", "--format",
|
|
166
|
+
"{{.Status.ContainerStatus.ContainerID}}",
|
|
167
|
+
task_ids[0],
|
|
168
|
+
)
|
|
169
|
+
actual_cid = cid_raw.strip()
|
|
170
|
+
except Exception as e:
|
|
171
|
+
yield f"[error obteniendo contenedor de la tarea: {e}]"
|
|
172
|
+
return
|
|
173
|
+
if not actual_cid:
|
|
174
|
+
yield "[error: no se encontró contenedor para la tarea]"
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
parts = shlex.split(command)
|
|
178
|
+
except ValueError:
|
|
179
|
+
parts = command.split()
|
|
180
|
+
cmd = ["exec", actual_cid] + parts
|
|
181
|
+
if self._ssh:
|
|
182
|
+
stream = await self._ssh.stream(["docker"] + cmd)
|
|
183
|
+
else:
|
|
184
|
+
proc = await asyncio.create_subprocess_exec(
|
|
185
|
+
self._docker_path, *cmd,
|
|
186
|
+
stdout=asyncio.subprocess.PIPE,
|
|
187
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
188
|
+
)
|
|
189
|
+
stream = proc.stdout
|
|
190
|
+
while True:
|
|
191
|
+
line = await stream.readline()
|
|
192
|
+
if not line:
|
|
193
|
+
break
|
|
194
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
195
|
+
|
|
196
|
+
async def close(self) -> None:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
async def _run(self, *args: str) -> str:
|
|
200
|
+
if self._ssh:
|
|
201
|
+
return await self._ssh.run(["docker"] + list(args))
|
|
202
|
+
proc = await asyncio.create_subprocess_exec(
|
|
203
|
+
self._docker_path,
|
|
204
|
+
*args,
|
|
205
|
+
stdout=asyncio.subprocess.PIPE,
|
|
206
|
+
stderr=asyncio.subprocess.PIPE,
|
|
207
|
+
)
|
|
208
|
+
stdout, stderr = await proc.communicate()
|
|
209
|
+
if proc.returncode != 0:
|
|
210
|
+
raise RuntimeError(f"docker {' '.join(args)} failed: {stderr.decode()}")
|
|
211
|
+
return stdout.decode("utf-8", errors="replace")
|
|
File without changes
|
termainer/remote/ssh.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SSHConnection:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
host: str,
|
|
14
|
+
user: Optional[str] = None,
|
|
15
|
+
key_path: Optional[str] = None,
|
|
16
|
+
password: Optional[str] = None,
|
|
17
|
+
port: int = 22,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.host = host
|
|
20
|
+
self.user = user # None → let SSH config resolve the user
|
|
21
|
+
self.key_path = os.path.expanduser(key_path) if key_path else None
|
|
22
|
+
self.password = password
|
|
23
|
+
self.port = port
|
|
24
|
+
self._use_sshpass = bool(password) and shutil.which("sshpass") is not None
|
|
25
|
+
self._password_warned = False
|
|
26
|
+
self._tunnel_proc: Optional[asyncio.subprocess.Process] = None
|
|
27
|
+
self._tunnel_socket: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
def _build_base_args(self) -> list[str]:
|
|
30
|
+
args = [
|
|
31
|
+
"-o", "StrictHostKeyChecking=no",
|
|
32
|
+
"-o", "ConnectTimeout=10",
|
|
33
|
+
"-o", "BatchMode=yes",
|
|
34
|
+
]
|
|
35
|
+
if self.key_path:
|
|
36
|
+
args.extend(["-i", self.key_path])
|
|
37
|
+
if self.port != 22:
|
|
38
|
+
args.extend(["-p", str(self.port)])
|
|
39
|
+
return args
|
|
40
|
+
|
|
41
|
+
def _target(self) -> str:
|
|
42
|
+
if self.user:
|
|
43
|
+
return f"{self.user}@{self.host}"
|
|
44
|
+
return self.host
|
|
45
|
+
|
|
46
|
+
def _build_command(self, remote_cmd: list[str]) -> list[str]:
|
|
47
|
+
if self._use_sshpass:
|
|
48
|
+
cmd = ["sshpass", "-e", "ssh"]
|
|
49
|
+
else:
|
|
50
|
+
cmd = ["ssh"]
|
|
51
|
+
cmd.extend(self._build_base_args())
|
|
52
|
+
cmd.append(self._target())
|
|
53
|
+
cmd.extend(remote_cmd)
|
|
54
|
+
return cmd
|
|
55
|
+
|
|
56
|
+
async def run(self, command: list[str]) -> str:
|
|
57
|
+
cmd = self._build_command(command)
|
|
58
|
+
env = os.environ.copy()
|
|
59
|
+
if self._use_sshpass:
|
|
60
|
+
env["SSHPASS"] = self.password
|
|
61
|
+
|
|
62
|
+
proc = await asyncio.create_subprocess_exec(
|
|
63
|
+
*cmd,
|
|
64
|
+
stdout=asyncio.subprocess.PIPE,
|
|
65
|
+
stderr=asyncio.subprocess.PIPE,
|
|
66
|
+
env=env,
|
|
67
|
+
)
|
|
68
|
+
stdout, stderr = await proc.communicate()
|
|
69
|
+
if proc.returncode != 0:
|
|
70
|
+
err = stderr.decode("utf-8", errors="replace").strip()
|
|
71
|
+
if not self._use_sshpass and self.password and not self._password_warned:
|
|
72
|
+
self._password_warned = True
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"SSH command failed: {err}\n"
|
|
75
|
+
"Hint: install 'sshpass' for password-based authentication"
|
|
76
|
+
)
|
|
77
|
+
raise RuntimeError(f"SSH command failed: {err}")
|
|
78
|
+
return stdout.decode("utf-8", errors="replace")
|
|
79
|
+
|
|
80
|
+
async def stream(self, command: list[str]) -> asyncio.StreamReader:
|
|
81
|
+
cmd = self._build_command(command)
|
|
82
|
+
env = os.environ.copy()
|
|
83
|
+
if self._use_sshpass:
|
|
84
|
+
env["SSHPASS"] = self.password
|
|
85
|
+
|
|
86
|
+
proc = await asyncio.create_subprocess_exec(
|
|
87
|
+
*cmd,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
90
|
+
env=env,
|
|
91
|
+
)
|
|
92
|
+
return proc.stdout
|
|
93
|
+
|
|
94
|
+
async def create_tunnel(
|
|
95
|
+
self,
|
|
96
|
+
remote_socket: str = "/var/run/docker.sock",
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Create an SSH tunnel forwarding a remote Unix socket to a local temp socket.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
remote_socket: Path to the remote Unix socket to forward.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Path to the local tunnel socket.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
RuntimeError: If the tunnel process fails to start.
|
|
108
|
+
"""
|
|
109
|
+
tmp = tempfile.mktemp(suffix=".sock", prefix="termainer-")
|
|
110
|
+
cmd = ["ssh", "-N", "-L", f"{tmp}:{remote_socket}"]
|
|
111
|
+
cmd.extend(self._build_base_args())
|
|
112
|
+
cmd.append(self._target())
|
|
113
|
+
|
|
114
|
+
proc = await asyncio.create_subprocess_exec(
|
|
115
|
+
*cmd,
|
|
116
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
117
|
+
stderr=asyncio.subprocess.PIPE,
|
|
118
|
+
)
|
|
119
|
+
# Wait for the socket to appear or process to fail
|
|
120
|
+
for _ in range(20):
|
|
121
|
+
if proc.returncode is not None:
|
|
122
|
+
_, stderr = await proc.communicate()
|
|
123
|
+
raise RuntimeError(
|
|
124
|
+
f"SSH tunnel failed: {stderr.decode('utf-8', errors='replace').strip()}"
|
|
125
|
+
)
|
|
126
|
+
if os.path.exists(tmp):
|
|
127
|
+
break
|
|
128
|
+
await asyncio.sleep(0.25)
|
|
129
|
+
else:
|
|
130
|
+
# Socket never appeared — kill the process
|
|
131
|
+
proc.kill()
|
|
132
|
+
await proc.wait()
|
|
133
|
+
raise RuntimeError("SSH tunnel timed out waiting for socket")
|
|
134
|
+
|
|
135
|
+
self._tunnel_proc = proc
|
|
136
|
+
self._tunnel_socket = tmp
|
|
137
|
+
return tmp
|
|
138
|
+
|
|
139
|
+
async def close_tunnel(self) -> None:
|
|
140
|
+
"""Close the SSH tunnel if one is open."""
|
|
141
|
+
if self._tunnel_proc:
|
|
142
|
+
try:
|
|
143
|
+
self._tunnel_proc.kill()
|
|
144
|
+
await self._tunnel_proc.wait()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
self._tunnel_proc = None
|
|
148
|
+
if self._tunnel_socket:
|
|
149
|
+
try:
|
|
150
|
+
os.unlink(self._tunnel_socket)
|
|
151
|
+
except (FileNotFoundError, OSError):
|
|
152
|
+
pass
|
|
153
|
+
self._tunnel_socket = None
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
user = self.user or ""
|
|
157
|
+
return f"{user}@{self.host}:{self.port}" if user else f"{self.host}:{self.port}"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from .providers.base import ContainerSummary, Provider
|
|
7
|
+
from .providers.docker import DockerProvider
|
|
8
|
+
from .providers.kubernetes import KubernetesProvider
|
|
9
|
+
from .providers.openshift import OpenShiftProvider
|
|
10
|
+
from .providers.podman import PodmanProvider
|
|
11
|
+
from .providers.swarm import SwarmProvider
|
|
12
|
+
from .remote.ssh import SSHConnection
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PROVIDER_MAP: dict[str, type] = {
|
|
16
|
+
"docker": DockerProvider,
|
|
17
|
+
"podman": PodmanProvider,
|
|
18
|
+
"kubernetes": KubernetesProvider,
|
|
19
|
+
"k8s": KubernetesProvider,
|
|
20
|
+
"openshift": OpenShiftProvider,
|
|
21
|
+
"swarm": SwarmProvider,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ServerConnection:
|
|
27
|
+
label: str
|
|
28
|
+
provider: Provider
|
|
29
|
+
ssh: Optional[SSHConnection] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ServerManager:
|
|
33
|
+
def __init__(self, servers: Optional[List[ServerConnection]] = None) -> None:
|
|
34
|
+
self.servers: List[ServerConnection] = servers or []
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def server_count(self) -> int:
|
|
38
|
+
return len(self.servers)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def server_labels(self) -> List[str]:
|
|
42
|
+
return [s.label for s in self.servers]
|
|
43
|
+
|
|
44
|
+
def get_provider(self, label: str) -> Provider:
|
|
45
|
+
for s in self.servers:
|
|
46
|
+
if s.label == label:
|
|
47
|
+
return s.provider
|
|
48
|
+
raise KeyError(f"Server '{label}' not found")
|
|
49
|
+
|
|
50
|
+
def has_local(self) -> bool:
|
|
51
|
+
return any(s.ssh is None for s in self.servers)
|
|
52
|
+
|
|
53
|
+
def get_connection(self, label: str) -> Optional[SSHConnection]:
|
|
54
|
+
for s in self.servers:
|
|
55
|
+
if s.label == label:
|
|
56
|
+
return s.ssh
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
async def list_all_containers(self) -> List[ContainerSummary]:
|
|
60
|
+
results: List[ContainerSummary] = []
|
|
61
|
+
for server in self.servers:
|
|
62
|
+
try:
|
|
63
|
+
containers = await server.provider.list_containers()
|
|
64
|
+
for c in containers:
|
|
65
|
+
c["_server"] = server.label
|
|
66
|
+
results.extend(containers)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return results
|
|
70
|
+
|
|
71
|
+
async def close_all(self) -> None:
|
|
72
|
+
for server in self.servers:
|
|
73
|
+
try:
|
|
74
|
+
await server.provider.close()
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def provider_class_for(name: str) -> type:
|
|
80
|
+
cls = PROVIDER_MAP.get(name.lower())
|
|
81
|
+
if cls is None:
|
|
82
|
+
available = ", ".join(sorted(PROVIDER_MAP))
|
|
83
|
+
raise RuntimeError(f"Unknown provider '{name}'. Available: {available}")
|
|
84
|
+
return cls
|
termainer/ssh_config.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Parser for ~/.ssh/config and SSH server discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SSHServer:
|
|
10
|
+
"""Represents a server entry from ~/.ssh/config."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
host: str,
|
|
15
|
+
hostname: Optional[str] = None,
|
|
16
|
+
user: Optional[str] = None,
|
|
17
|
+
port: int = 22,
|
|
18
|
+
identity_file: Optional[str] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.host = host # The "Host" identifier (connection alias)
|
|
21
|
+
self.hostname = hostname or host # Actual hostname to connect to
|
|
22
|
+
self.user = user # None = let SSH use its default (local user)
|
|
23
|
+
self.port = port
|
|
24
|
+
self.identity_file = identity_file
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def display_name(self) -> str:
|
|
28
|
+
"""User-friendly display name."""
|
|
29
|
+
if self.hostname != self.host:
|
|
30
|
+
return f"{self.host} ({self.hostname})"
|
|
31
|
+
return self.host
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return f"SSHServer(host={self.host}, hostname={self.hostname}, user={self.user}, port={self.port})"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_ssh_config(config_path: Optional[str] = None) -> Dict[str, SSHServer]:
|
|
38
|
+
"""
|
|
39
|
+
Parse ~/.ssh/config file and return a dict of SSHServer objects.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config_path: Optional path to SSH config file. Defaults to ~/.ssh/config
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dict mapping connection aliases to SSHServer objects
|
|
46
|
+
"""
|
|
47
|
+
if config_path is None:
|
|
48
|
+
config_path = str(Path.home() / ".ssh" / "config")
|
|
49
|
+
|
|
50
|
+
ssh_servers: Dict[str, SSHServer] = {}
|
|
51
|
+
|
|
52
|
+
config_file = Path(config_path)
|
|
53
|
+
if not config_file.exists():
|
|
54
|
+
return ssh_servers
|
|
55
|
+
|
|
56
|
+
current_host: Optional[str] = None
|
|
57
|
+
current_config: Dict[str, str] = {}
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with open(config_file) as f:
|
|
61
|
+
for line in f:
|
|
62
|
+
line = line.strip()
|
|
63
|
+
|
|
64
|
+
# Skip comments and empty lines
|
|
65
|
+
if not line or line.startswith("#"):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Split key-value pairs (SSH config uses space-separated)
|
|
69
|
+
parts = line.split(None, 1)
|
|
70
|
+
if len(parts) != 2:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
key, value = parts[0].lower(), parts[1].strip()
|
|
74
|
+
|
|
75
|
+
# New host definition
|
|
76
|
+
if key == "host":
|
|
77
|
+
# Save previous host if it exists
|
|
78
|
+
if current_host:
|
|
79
|
+
ssh_servers[current_host] = _build_ssh_server(current_host, current_config)
|
|
80
|
+
current_host = value
|
|
81
|
+
current_config = {}
|
|
82
|
+
else:
|
|
83
|
+
# Collect config directives
|
|
84
|
+
if current_host: # Only collect if we're in a Host block
|
|
85
|
+
current_config[key] = value
|
|
86
|
+
|
|
87
|
+
# Don't forget the last host
|
|
88
|
+
if current_host:
|
|
89
|
+
ssh_servers[current_host] = _build_ssh_server(current_host, current_config)
|
|
90
|
+
|
|
91
|
+
except (IOError, OSError):
|
|
92
|
+
pass # Return empty dict if file can't be read
|
|
93
|
+
|
|
94
|
+
return ssh_servers
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_ssh_server(host: str, config: Dict[str, str]) -> SSHServer:
|
|
98
|
+
"""Build an SSHServer object from parsed config dict."""
|
|
99
|
+
hostname = config.get("hostname", host)
|
|
100
|
+
user = config.get("user") # None if not specified → let SSH use local user
|
|
101
|
+
port = int(config.get("port", "22"))
|
|
102
|
+
identity_file = config.get("identityfile")
|
|
103
|
+
|
|
104
|
+
return SSHServer(
|
|
105
|
+
host=host,
|
|
106
|
+
hostname=hostname,
|
|
107
|
+
user=user,
|
|
108
|
+
port=port,
|
|
109
|
+
identity_file=identity_file,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_ssh_servers() -> Dict[str, SSHServer]:
|
|
114
|
+
"""
|
|
115
|
+
Convenience function to get all SSH servers from ~/.ssh/config.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dict mapping connection aliases to SSHServer objects
|
|
119
|
+
"""
|
|
120
|
+
return parse_ssh_config()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def filter_ssh_servers_for_container_mgmt(servers: Dict[str, SSHServer]) -> Dict[str, SSHServer]:
|
|
124
|
+
"""
|
|
125
|
+
Filter SSH servers that likely have container runtimes.
|
|
126
|
+
Simple heuristic: exclude localhost and 127.0.0.1.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
servers: Dict of SSHServer objects
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Filtered dict excluding local servers
|
|
133
|
+
"""
|
|
134
|
+
return {
|
|
135
|
+
alias: server
|
|
136
|
+
for alias, server in servers.items()
|
|
137
|
+
if server.hostname not in ("localhost", "127.0.0.1")
|
|
138
|
+
}
|
termainer/ui/__init__.py
ADDED
|
File without changes
|