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
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)
|