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 ADDED
File without changes
termainer/app.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import argparse
5
+ import os
6
+ from typing import List, Optional, Type
7
+
8
+ from textual.app import App
9
+
10
+ from .config import build_ssh_from_env, load_env_file
11
+ from .config_manager import ServerConfig, load_config
12
+ from .locale import _
13
+ from .providers.base import Provider
14
+ from .providers.docker import DockerProvider
15
+ from .providers.kubernetes import KubernetesProvider
16
+ from .providers.openshift import OpenShiftProvider
17
+ from .providers.podman import PodmanProvider
18
+ from .providers.swarm import SwarmProvider
19
+ from .remote.ssh import SSHConnection
20
+ from .server_manager import ServerConnection, ServerManager, provider_class_for
21
+ from .ui.splash import BootScreen
22
+ from .version import VERSION
23
+
24
+
25
+ class TermainerApp(App):
26
+ TITLE = "Termainer"
27
+ CSS_PATH = "ui/styles.tcss"
28
+
29
+ def __init__(self, server_manager: ServerManager) -> None:
30
+ super().__init__()
31
+ self.server_manager = server_manager
32
+ self.sub_title = _("app.subtitle")
33
+
34
+ def on_mount(self) -> None:
35
+ self.push_screen(BootScreen(self.server_manager))
36
+
37
+
38
+ async def detect_provider(ssh: Optional[SSHConnection] = None) -> Provider:
39
+ providers: List[Type] = [
40
+ DockerProvider,
41
+ SwarmProvider,
42
+ KubernetesProvider,
43
+ PodmanProvider,
44
+ OpenShiftProvider,
45
+ ]
46
+ for cls in providers:
47
+ instance = cls(ssh=ssh) if ssh else cls()
48
+ if await instance.is_available():
49
+ return instance
50
+ raise RuntimeError(_("app.error.no_runtime"))
51
+
52
+
53
+ async def detect_available_providers(ssh: Optional[SSHConnection] = None) -> List[Provider]:
54
+ providers: List[Type] = [
55
+ DockerProvider,
56
+ SwarmProvider,
57
+ KubernetesProvider,
58
+ PodmanProvider,
59
+ OpenShiftProvider,
60
+ ]
61
+ available: List[Provider] = []
62
+ for cls in providers:
63
+ instance = cls(ssh=ssh) if ssh else cls()
64
+ if await instance.is_available():
65
+ available.append(instance)
66
+ return available
67
+
68
+
69
+ async def create_provider(
70
+ provider_name: str | None = None,
71
+ ssh: Optional[SSHConnection] = None,
72
+ ) -> Provider:
73
+ providers: dict[str, Type] = {
74
+ "docker": DockerProvider,
75
+ "swarm": SwarmProvider,
76
+ "podman": PodmanProvider,
77
+ "kubernetes": KubernetesProvider,
78
+ "k8s": KubernetesProvider,
79
+ "openshift": OpenShiftProvider,
80
+ }
81
+ if not provider_name or provider_name == "auto":
82
+ return await detect_provider(ssh)
83
+
84
+ cls = providers.get(provider_name.lower())
85
+ if cls is None:
86
+ available = ", ".join(sorted(providers))
87
+ raise RuntimeError(_("app.error.unknown_provider", provider=provider_name, available=available))
88
+
89
+ instance = cls(ssh=ssh) if ssh else cls()
90
+ if not await instance.is_available():
91
+ raise RuntimeError(_("app.error.provider_not_available", provider=provider_name))
92
+ return instance
93
+
94
+
95
+ async def build_server_manager(
96
+ config_servers: List[ServerConfig],
97
+ ssh: Optional[SSHConnection],
98
+ cli_provider: str,
99
+ ) -> ServerManager:
100
+ connections: List[ServerConnection] = []
101
+
102
+ if config_servers:
103
+ for sc in config_servers:
104
+ ssh_conn: Optional[SSHConnection] = None
105
+ if sc.host:
106
+ ssh_conn = SSHConnection(
107
+ host=sc.host,
108
+ user=sc.user,
109
+ key_path=sc.key_path,
110
+ password=sc.password,
111
+ port=sc.port,
112
+ )
113
+ provider_cls = provider_class_for(sc.provider)
114
+ provider = provider_cls(ssh=ssh_conn) if ssh_conn else provider_cls()
115
+ connections.append(ServerConnection(label=sc.label, provider=provider, ssh=ssh_conn))
116
+ return ServerManager(connections)
117
+
118
+ provider_name = cli_provider
119
+ if provider_name == "auto" and ssh:
120
+ from .config import get_provider_from_env
121
+
122
+ env = load_env_file(".env")
123
+ provider_name = get_provider_from_env(env) or "auto"
124
+
125
+ if provider_name == "auto" and ssh is None:
126
+ # Local auto-detect mode: solo proveedores locales, sin conexiones SSH al startup
127
+ available = await detect_available_providers(ssh=None)
128
+ if not available:
129
+ raise RuntimeError(_("app.error.no_runtime"))
130
+ for provider in available:
131
+ label = _("app.server.local_label", provider=provider.name.capitalize())
132
+ connections.append(ServerConnection(label=label, provider=provider, ssh=None))
133
+
134
+ return ServerManager(connections)
135
+
136
+ provider = await create_provider(provider_name, ssh)
137
+ label = _("app.server.remote_label", provider=provider.name.capitalize(), host=ssh.host) if ssh else _("app.server.local_label", provider=provider.name.capitalize())
138
+ connections.append(ServerConnection(label=label, provider=provider, ssh=ssh))
139
+ return ServerManager(connections)
140
+
141
+
142
+ def parse_args() -> argparse.Namespace:
143
+ parser = argparse.ArgumentParser(
144
+ description="Termainer — Container observability and operations from your terminal"
145
+ )
146
+ parser.add_argument(
147
+ "--provider",
148
+ choices=("auto", "docker", "swarm", "podman", "kubernetes", "k8s", "openshift"),
149
+ default="auto",
150
+ help="Container runtime provider to use",
151
+ )
152
+ parser.add_argument(
153
+ "--host",
154
+ help="Remote SSH host (IP or hostname). Overrides TERMAINER_REMOTE_HOST in .env",
155
+ )
156
+ parser.add_argument(
157
+ "--ssh-user",
158
+ default=None,
159
+ help="SSH user (default: root, or TERMAINER_REMOTE_USER from .env)",
160
+ )
161
+ parser.add_argument(
162
+ "--ssh-key",
163
+ default=None,
164
+ help="SSH private key path (.pem). Overrides TERMAINER_REMOTE_KEY_PATH in .env",
165
+ )
166
+ parser.add_argument(
167
+ "--ssh-password",
168
+ default=None,
169
+ help="SSH password. Overrides TERMAINER_REMOTE_PASSWORD in .env",
170
+ )
171
+ parser.add_argument(
172
+ "--ssh-port",
173
+ type=int,
174
+ default=None,
175
+ help="SSH port (default: 22, or TERMAINER_REMOTE_PORT from .env)",
176
+ )
177
+ parser.add_argument(
178
+ "--version",
179
+ action="version",
180
+ version=f"%(prog)s {VERSION}",
181
+ help="Show version and exit",
182
+ )
183
+ parser.add_argument(
184
+ "--env",
185
+ default=".env",
186
+ dest="env_file",
187
+ help="Path to .env file for remote configuration (default: .env)",
188
+ )
189
+ parser.add_argument(
190
+ "--config",
191
+ default=None,
192
+ dest="config_file",
193
+ help="Path to config.yaml for multi-server setup (default: auto-detect XDG path)",
194
+ )
195
+ return parser.parse_args()
196
+
197
+
198
+ def main() -> None:
199
+ from .locale import init as locale_init
200
+
201
+ # Initialize locale from env var first, then override with .env
202
+ locale_init()
203
+ args = parse_args()
204
+
205
+ env_file = args.env_file
206
+ env = load_env_file(env_file) if os.path.isfile(env_file) else {}
207
+ locale_init(env)
208
+
209
+ server_manager: Optional[ServerManager] = None
210
+
211
+ if args.config_file:
212
+ from .config_manager import _parse_yaml
213
+
214
+ path = os.path.expanduser(args.config_file)
215
+ app_cfg = _parse_yaml(path) if os.path.isfile(path) else load_config()
216
+ else:
217
+ app_cfg = load_config()
218
+
219
+ if app_cfg.servers:
220
+ server_manager = asyncio.run(
221
+ build_server_manager(app_cfg.servers, ssh=None, cli_provider="auto")
222
+ )
223
+ else:
224
+ ssh = build_ssh_from_env(
225
+ env,
226
+ cli_host=args.host,
227
+ cli_user=args.ssh_user,
228
+ cli_key=args.ssh_key,
229
+ cli_password=args.ssh_password,
230
+ cli_port=args.ssh_port,
231
+ )
232
+
233
+ server_manager = asyncio.run(
234
+ build_server_manager([], ssh=ssh, cli_provider=args.provider)
235
+ )
236
+
237
+ app = TermainerApp(server_manager)
238
+ app.run()
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()
termainer/config.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Optional
4
+
5
+ from .remote.ssh import SSHConnection
6
+ from .ssh_config import SSHServer, get_ssh_servers, filter_ssh_servers_for_container_mgmt
7
+
8
+
9
+ def load_env_file(path: str = ".env") -> Dict[str, str]:
10
+ env: Dict[str, str] = {}
11
+ try:
12
+ with open(path) as f:
13
+ for line in f:
14
+ line = line.strip()
15
+ if not line or line.startswith("#"):
16
+ continue
17
+ if "=" in line:
18
+ key, _, val = line.partition("=")
19
+ env[key.strip()] = val.strip().strip("'\"")
20
+ except FileNotFoundError:
21
+ pass
22
+ return env
23
+
24
+
25
+ def build_ssh_from_env(
26
+ env: Dict[str, str],
27
+ cli_host: Optional[str] = None,
28
+ cli_user: Optional[str] = None,
29
+ cli_key: Optional[str] = None,
30
+ cli_password: Optional[str] = None,
31
+ cli_port: Optional[int] = None,
32
+ ) -> Optional[SSHConnection]:
33
+ host = cli_host or env.get("TERMAINER_REMOTE_HOST")
34
+ if not host:
35
+ return None
36
+
37
+ return SSHConnection(
38
+ host=host,
39
+ user=cli_user or env.get("TERMAINER_REMOTE_USER", "root"),
40
+ key_path=cli_key or env.get("TERMAINER_REMOTE_KEY_PATH"),
41
+ password=cli_password or env.get("TERMAINER_REMOTE_PASSWORD"),
42
+ port=cli_port or int(env.get("TERMAINER_REMOTE_PORT", "22")),
43
+ )
44
+
45
+
46
+ def get_provider_from_env(env: Dict[str, str]) -> Optional[str]:
47
+ return env.get("TERMAINER_REMOTE_PROVIDER") or None
48
+
49
+
50
+ def build_ssh_from_ssh_server(
51
+ ssh_server: SSHServer,
52
+ cli_password: Optional[str] = None,
53
+ ) -> SSHConnection:
54
+ """Build SSHConnection from an SSHServer object (parsed from ~/.ssh/config).
55
+
56
+ Uses the Host alias so SSH config matching works correctly (ProxyJump, etc).
57
+ User/key/port are passed explicitly only when specified in the config entry.
58
+ """
59
+ return SSHConnection(
60
+ host=ssh_server.host,
61
+ user=ssh_server.user,
62
+ key_path=ssh_server.identity_file,
63
+ password=cli_password,
64
+ port=ssh_server.port,
65
+ )
66
+
67
+
68
+ def get_configured_ssh_servers() -> Dict[str, SSHServer]:
69
+ """
70
+ Get SSH servers from ~/.ssh/config that are suitable for container management.
71
+ Filters out localhost entries.
72
+
73
+ Returns:
74
+ Dict mapping connection aliases to SSHServer objects
75
+ """
76
+ all_servers = get_ssh_servers()
77
+ return filter_ssh_servers_for_container_mgmt(all_servers)
78
+
79
+
80
+ def has_ssh_servers_configured() -> bool:
81
+ """Check if any SSH servers are configured in ~/.ssh/config."""
82
+ return bool(get_configured_ssh_servers())
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ CONFIG_DIR_NAME = "termainer"
10
+ CONFIG_FILE_NAME = "config.yaml"
11
+
12
+
13
+ @dataclass
14
+ class ServerConfig:
15
+ label: str
16
+ provider: str
17
+ host: Optional[str] = None
18
+ user: str = "root"
19
+ key_path: Optional[str] = None
20
+ password: Optional[str] = None
21
+ port: int = 22
22
+
23
+
24
+ @dataclass
25
+ class AppConfig:
26
+ lang: str = "en"
27
+ servers: List[ServerConfig] = field(default_factory=list)
28
+
29
+
30
+ def _get_config_paths() -> List[Path]:
31
+ paths = []
32
+ xdg = os.environ.get("XDG_CONFIG_HOME")
33
+ if xdg:
34
+ paths.append(Path(xdg) / CONFIG_DIR_NAME / CONFIG_FILE_NAME)
35
+ paths.append(Path.home() / ".config" / CONFIG_DIR_NAME / CONFIG_FILE_NAME)
36
+ paths.append(Path.cwd() / CONFIG_FILE_NAME)
37
+ return paths
38
+
39
+
40
+ def load_config() -> AppConfig:
41
+ config_paths = _get_config_paths()
42
+ for path in config_paths:
43
+ if path.exists():
44
+ return _parse_yaml(path)
45
+ return AppConfig()
46
+
47
+
48
+ def _parse_yaml(path: Path) -> AppConfig:
49
+ try:
50
+ import yaml
51
+ except ImportError:
52
+ raise RuntimeError(
53
+ "PyYAML is required to load config.yaml. "
54
+ "Install it with: pip install pyyaml"
55
+ )
56
+
57
+ with open(path) as f:
58
+ data: Dict[str, Any] = yaml.safe_load(f) or {}
59
+
60
+ lang = data.get("lang", "en")
61
+ servers_raw: List[Dict[str, Any]] = data.get("servers", [])
62
+ servers = []
63
+ for s in servers_raw:
64
+ servers.append(
65
+ ServerConfig(
66
+ label=s["label"],
67
+ provider=s["provider"],
68
+ host=s.get("host"),
69
+ user=s.get("user", "root"),
70
+ key_path=s.get("key") or s.get("key_path"),
71
+ password=s.get("password"),
72
+ port=s.get("port", 22),
73
+ )
74
+ )
75
+ return AppConfig(lang=lang, servers=servers)