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.
@@ -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
@@ -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
@@ -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
+ }
File without changes