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,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import AsyncIterator, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from ..remote.ssh import SSHConnection
|
|
11
|
+
from .base import ContainerDetails, ContainerStats, ContainerSummary
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DockerProvider:
|
|
18
|
+
name = "docker"
|
|
19
|
+
|
|
20
|
+
def __init__(self, ssh: Optional[SSHConnection] = None) -> None:
|
|
21
|
+
self._docker_path: Optional[str] = None
|
|
22
|
+
self._ssh = ssh
|
|
23
|
+
|
|
24
|
+
async def is_available(self) -> bool:
|
|
25
|
+
if self._ssh:
|
|
26
|
+
try:
|
|
27
|
+
await self._ssh.run(["docker", "info"])
|
|
28
|
+
self._docker_path = "docker"
|
|
29
|
+
return True
|
|
30
|
+
except RuntimeError:
|
|
31
|
+
return False
|
|
32
|
+
self._docker_path = shutil.which("docker")
|
|
33
|
+
if not self._docker_path:
|
|
34
|
+
return False
|
|
35
|
+
proc = await asyncio.create_subprocess_exec(
|
|
36
|
+
self._docker_path, "info",
|
|
37
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
38
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
39
|
+
)
|
|
40
|
+
code = await proc.wait()
|
|
41
|
+
return code == 0
|
|
42
|
+
|
|
43
|
+
async def list_containers(self) -> List[ContainerSummary]:
|
|
44
|
+
raw_ids = await self._run("ps", "-a", "-q", "--no-trunc")
|
|
45
|
+
ids = [i.strip() for i in raw_ids.split("\n") if i.strip()]
|
|
46
|
+
if not ids:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
raw_details = await self._run("inspect", *ids)
|
|
50
|
+
data = json.loads(raw_details)
|
|
51
|
+
if not isinstance(data, list):
|
|
52
|
+
data = [data]
|
|
53
|
+
|
|
54
|
+
containers = []
|
|
55
|
+
for item in data:
|
|
56
|
+
cid = item.get("Id", "")
|
|
57
|
+
if cid.startswith("sha256:"):
|
|
58
|
+
cid = cid[7:]
|
|
59
|
+
config = item.get("Config", {}) or {}
|
|
60
|
+
state = item.get("State", {}) or {}
|
|
61
|
+
net_settings = item.get("NetworkSettings", {}) or {}
|
|
62
|
+
host_config = item.get("HostConfig", {}) or {}
|
|
63
|
+
|
|
64
|
+
ports_raw = net_settings.get("Ports", {}) or {}
|
|
65
|
+
port_str = ", ".join(
|
|
66
|
+
f"{b[0]['HostPort']}->{port}"
|
|
67
|
+
if b and isinstance(b, list) and len(b) > 0 and "HostPort" in b[0]
|
|
68
|
+
else port
|
|
69
|
+
for port, b in ports_raw.items()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
networks_raw = net_settings.get("Networks", {}) or {}
|
|
73
|
+
net_str = ", ".join(networks_raw.keys())
|
|
74
|
+
|
|
75
|
+
restart = (host_config.get("RestartPolicy", {}) or {}).get("Name", "")
|
|
76
|
+
|
|
77
|
+
containers.append({
|
|
78
|
+
"id": cid,
|
|
79
|
+
"names": item.get("Name", "").lstrip("/"),
|
|
80
|
+
"image": config.get("Image", ""),
|
|
81
|
+
"status": state.get("Status", "unknown"),
|
|
82
|
+
"createdat": item.get("Created", ""),
|
|
83
|
+
"ports": port_str,
|
|
84
|
+
"networks": net_str,
|
|
85
|
+
"restartpolicy": restart,
|
|
86
|
+
})
|
|
87
|
+
return containers
|
|
88
|
+
|
|
89
|
+
async def inspect(self, container_id: str) -> ContainerDetails:
|
|
90
|
+
raw = await self._run("inspect", container_id)
|
|
91
|
+
data = json.loads(raw)
|
|
92
|
+
if isinstance(data, list):
|
|
93
|
+
return data[0] if data else {}
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
async def stats(self, container_id: str) -> AsyncIterator[ContainerStats]:
|
|
97
|
+
if self._ssh:
|
|
98
|
+
stream = await self._ssh.stream(
|
|
99
|
+
["docker", "stats", "--format", "{{json .}}", container_id]
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
proc = await asyncio.create_subprocess_exec(
|
|
103
|
+
self._docker_path, "stats", "--format", "{{json .}}", container_id,
|
|
104
|
+
stdout=asyncio.subprocess.PIPE,
|
|
105
|
+
stderr=asyncio.subprocess.PIPE,
|
|
106
|
+
)
|
|
107
|
+
stream = proc.stdout
|
|
108
|
+
while True:
|
|
109
|
+
line = await stream.readline()
|
|
110
|
+
if not line:
|
|
111
|
+
break
|
|
112
|
+
raw = _ANSI_RE.sub("", line.decode("utf-8", errors="replace")).strip()
|
|
113
|
+
if raw:
|
|
114
|
+
yield json.loads(raw)
|
|
115
|
+
|
|
116
|
+
async def logs(
|
|
117
|
+
self, container_id: str, tail: int = 100, follow: bool = False
|
|
118
|
+
) -> AsyncIterator[str]:
|
|
119
|
+
cmd = ["logs", "--tail", str(tail)]
|
|
120
|
+
if follow:
|
|
121
|
+
cmd.append("-f")
|
|
122
|
+
cmd.append(container_id)
|
|
123
|
+
|
|
124
|
+
if self._ssh:
|
|
125
|
+
stream = await self._ssh.stream(["docker"] + cmd)
|
|
126
|
+
else:
|
|
127
|
+
proc = await asyncio.create_subprocess_exec(
|
|
128
|
+
self._docker_path, *cmd,
|
|
129
|
+
stdout=asyncio.subprocess.PIPE,
|
|
130
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
131
|
+
)
|
|
132
|
+
stream = proc.stdout
|
|
133
|
+
while True:
|
|
134
|
+
line = await stream.readline()
|
|
135
|
+
if not line:
|
|
136
|
+
break
|
|
137
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
138
|
+
if not follow:
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
async def get_env(self, container_id: str) -> Dict[str, str]:
|
|
142
|
+
details = await self.inspect(container_id)
|
|
143
|
+
env_list: List[str] = (
|
|
144
|
+
details.get("Config", {}).get("Env", [])
|
|
145
|
+
)
|
|
146
|
+
env_dict: Dict[str, str] = {}
|
|
147
|
+
for entry in env_list:
|
|
148
|
+
if "=" in entry:
|
|
149
|
+
key, _, val = entry.partition("=")
|
|
150
|
+
env_dict[key] = val
|
|
151
|
+
return env_dict
|
|
152
|
+
|
|
153
|
+
async def start(self, container_id: str) -> None:
|
|
154
|
+
await self._run("start", container_id)
|
|
155
|
+
|
|
156
|
+
async def stop(self, container_id: str) -> None:
|
|
157
|
+
await self._run("stop", container_id)
|
|
158
|
+
|
|
159
|
+
async def restart(self, container_id: str) -> None:
|
|
160
|
+
await self._run("restart", container_id)
|
|
161
|
+
|
|
162
|
+
async def remove(self, container_id: str, force: bool = False) -> None:
|
|
163
|
+
args = ["rm"]
|
|
164
|
+
if force:
|
|
165
|
+
args.append("-f")
|
|
166
|
+
args.append(container_id)
|
|
167
|
+
await self._run(*args)
|
|
168
|
+
|
|
169
|
+
async def set_restart_policy(self, container_id: str, policy: str) -> None:
|
|
170
|
+
await self._run("update", "--restart", policy, container_id)
|
|
171
|
+
|
|
172
|
+
async def exec_command(self, container_id: str, command: str) -> AsyncIterator[str]:
|
|
173
|
+
try:
|
|
174
|
+
parts = shlex.split(command)
|
|
175
|
+
except ValueError:
|
|
176
|
+
parts = command.split()
|
|
177
|
+
cmd = ["exec", container_id] + parts
|
|
178
|
+
if self._ssh:
|
|
179
|
+
stream = await self._ssh.stream(["docker"] + cmd)
|
|
180
|
+
else:
|
|
181
|
+
proc = await asyncio.create_subprocess_exec(
|
|
182
|
+
self._docker_path, *cmd,
|
|
183
|
+
stdout=asyncio.subprocess.PIPE,
|
|
184
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
185
|
+
)
|
|
186
|
+
stream = proc.stdout
|
|
187
|
+
while True:
|
|
188
|
+
line = await stream.readline()
|
|
189
|
+
if not line:
|
|
190
|
+
break
|
|
191
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
192
|
+
|
|
193
|
+
async def close(self) -> None:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
async def _run(self, *args: str) -> str:
|
|
197
|
+
if self._ssh:
|
|
198
|
+
return await self._ssh.run(["docker"] + list(args))
|
|
199
|
+
if self._docker_path is None:
|
|
200
|
+
self._docker_path = shutil.which("docker")
|
|
201
|
+
if self._docker_path is None:
|
|
202
|
+
raise RuntimeError("docker not found in PATH")
|
|
203
|
+
proc = await asyncio.create_subprocess_exec(
|
|
204
|
+
self._docker_path, *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(
|
|
211
|
+
f"docker {' '.join(args)} failed: {stderr.decode()}"
|
|
212
|
+
)
|
|
213
|
+
return stdout.decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import AsyncIterator, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ..remote.ssh import SSHConnection
|
|
11
|
+
from .base import ContainerDetails, ContainerStats, ContainerSummary
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KubernetesProvider:
|
|
15
|
+
name = "kubernetes"
|
|
16
|
+
|
|
17
|
+
def __init__(self, ssh: Optional[SSHConnection] = None) -> None:
|
|
18
|
+
self._kubectl_path: Optional[str] = None
|
|
19
|
+
self._ssh = ssh
|
|
20
|
+
|
|
21
|
+
async def is_available(self) -> bool:
|
|
22
|
+
if self._ssh:
|
|
23
|
+
try:
|
|
24
|
+
await self._ssh.run(["kubectl", "cluster-info"])
|
|
25
|
+
self._kubectl_path = "kubectl"
|
|
26
|
+
return True
|
|
27
|
+
except RuntimeError:
|
|
28
|
+
return False
|
|
29
|
+
self._kubectl_path = shutil.which("kubectl")
|
|
30
|
+
if not self._kubectl_path:
|
|
31
|
+
return False
|
|
32
|
+
proc = await asyncio.create_subprocess_exec(
|
|
33
|
+
self._kubectl_path, "cluster-info",
|
|
34
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
35
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
36
|
+
)
|
|
37
|
+
code = await proc.wait()
|
|
38
|
+
return code == 0
|
|
39
|
+
|
|
40
|
+
async def list_containers(self) -> List[ContainerSummary]:
|
|
41
|
+
raw = await self._run(
|
|
42
|
+
"get", "pods", "--all-namespaces", "-o", "json"
|
|
43
|
+
)
|
|
44
|
+
data = json.loads(raw)
|
|
45
|
+
pods = []
|
|
46
|
+
for item in data.get("items", []):
|
|
47
|
+
status = item.get("status", {})
|
|
48
|
+
spec = item.get("spec", {})
|
|
49
|
+
metadata = item.get("metadata", {})
|
|
50
|
+
container_statuses = status.get("containerStatuses", [])
|
|
51
|
+
containers = spec.get("containers", [])
|
|
52
|
+
ready = sum(1 for c in container_statuses if c.get("ready"))
|
|
53
|
+
total = len(containers)
|
|
54
|
+
restarts = sum(int(c.get("restartCount", 0)) for c in container_statuses)
|
|
55
|
+
images = [c.get("image", "") for c in containers]
|
|
56
|
+
namespace = metadata.get("namespace", "default")
|
|
57
|
+
name = metadata.get("name", "unknown")
|
|
58
|
+
pods.append({
|
|
59
|
+
"id": f"{namespace}/{name}",
|
|
60
|
+
"name": name,
|
|
61
|
+
"namespace": namespace,
|
|
62
|
+
"status": status.get("phase", "Unknown"),
|
|
63
|
+
"image": ", ".join(images),
|
|
64
|
+
"node": spec.get("nodeName", ""),
|
|
65
|
+
"ready": f"{ready}/{total}",
|
|
66
|
+
"restart": str(restarts),
|
|
67
|
+
"created": self._age(metadata.get("creationTimestamp", "")),
|
|
68
|
+
"networks": status.get("podIP", ""),
|
|
69
|
+
"ports": self._ports(containers),
|
|
70
|
+
"containers": [c.get("name", "") for c in containers],
|
|
71
|
+
"raw": item,
|
|
72
|
+
})
|
|
73
|
+
return pods
|
|
74
|
+
|
|
75
|
+
async def inspect(self, container_id: str) -> ContainerDetails:
|
|
76
|
+
namespace, name = self._parse_id(container_id)
|
|
77
|
+
raw = await self._run(
|
|
78
|
+
"get", "pod", name, "-n", namespace, "-o", "json"
|
|
79
|
+
)
|
|
80
|
+
return json.loads(raw)
|
|
81
|
+
|
|
82
|
+
async def stats(self, container_id: str) -> AsyncIterator[ContainerStats]:
|
|
83
|
+
namespace, name = self._parse_id(container_id)
|
|
84
|
+
while True:
|
|
85
|
+
try:
|
|
86
|
+
raw = await self._run(
|
|
87
|
+
"top", "pod", name, "-n", namespace
|
|
88
|
+
)
|
|
89
|
+
lines = raw.strip().split("\n")
|
|
90
|
+
if len(lines) >= 2:
|
|
91
|
+
parts = lines[1].split()
|
|
92
|
+
if len(parts) >= 3:
|
|
93
|
+
yield {
|
|
94
|
+
"pod": name,
|
|
95
|
+
"namespace": namespace,
|
|
96
|
+
"cpu": parts[1],
|
|
97
|
+
"memory": parts[2],
|
|
98
|
+
}
|
|
99
|
+
except RuntimeError:
|
|
100
|
+
yield {"pod": name, "namespace": namespace, "cpu": "N/A", "memory": "N/A"}
|
|
101
|
+
await asyncio.sleep(2)
|
|
102
|
+
|
|
103
|
+
async def logs(
|
|
104
|
+
self, container_id: str, tail: int = 100, follow: bool = False
|
|
105
|
+
) -> AsyncIterator[str]:
|
|
106
|
+
namespace, name = self._parse_id(container_id)
|
|
107
|
+
cmd = ["logs", "--tail", str(tail), name, "-n", namespace]
|
|
108
|
+
if follow:
|
|
109
|
+
cmd.append("-f")
|
|
110
|
+
|
|
111
|
+
if self._ssh:
|
|
112
|
+
stream = await self._ssh.stream(["kubectl"] + cmd)
|
|
113
|
+
else:
|
|
114
|
+
proc = await asyncio.create_subprocess_exec(
|
|
115
|
+
self._kubectl_path, *cmd,
|
|
116
|
+
stdout=asyncio.subprocess.PIPE,
|
|
117
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
118
|
+
)
|
|
119
|
+
stream = proc.stdout
|
|
120
|
+
while True:
|
|
121
|
+
line = await stream.readline()
|
|
122
|
+
if not line:
|
|
123
|
+
break
|
|
124
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
125
|
+
if not follow:
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
async def get_env(self, container_id: str) -> Dict[str, str]:
|
|
129
|
+
details = await self.inspect(container_id)
|
|
130
|
+
env_dict: Dict[str, str] = {}
|
|
131
|
+
containers = (
|
|
132
|
+
details.get("spec", {}).get("containers", [])
|
|
133
|
+
)
|
|
134
|
+
for container in containers:
|
|
135
|
+
for env in container.get("env", []):
|
|
136
|
+
key = env.get("name", "")
|
|
137
|
+
val = env.get("value", "")
|
|
138
|
+
if not val and env.get("valueFrom"):
|
|
139
|
+
val = "<valueFrom>"
|
|
140
|
+
if key:
|
|
141
|
+
env_dict[key] = val
|
|
142
|
+
return env_dict
|
|
143
|
+
|
|
144
|
+
async def start(self, container_id: str) -> None:
|
|
145
|
+
raise RuntimeError("Start no aplica a pods de Kubernetes")
|
|
146
|
+
|
|
147
|
+
async def stop(self, container_id: str) -> None:
|
|
148
|
+
raise RuntimeError("Stop no aplica a pods de Kubernetes; usa delete/scale en una futura vista Kubernetes")
|
|
149
|
+
|
|
150
|
+
async def restart(self, container_id: str) -> None:
|
|
151
|
+
raise RuntimeError("Restart de Kubernetes se implementara sobre deployments/rollouts")
|
|
152
|
+
|
|
153
|
+
async def remove(self, container_id: str, force: bool = False) -> None:
|
|
154
|
+
namespace, name = self._parse_id(container_id)
|
|
155
|
+
await self._run("delete", "pod", name, "-n", namespace)
|
|
156
|
+
|
|
157
|
+
async def set_restart_policy(self, container_id: str, policy: str) -> None:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
"En Kubernetes la política de reinicio (restartPolicy) está definida en el "
|
|
160
|
+
"manifiesto del Deployment/StatefulSet y no puede cambiarse en un pod en ejecución. "
|
|
161
|
+
"Usa 'kubectl edit deployment <nombre>' para modificarla y forzar un rollout."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def exec_command(self, container_id: str, command: str) -> AsyncIterator[str]:
|
|
165
|
+
namespace, name = self._parse_id(container_id)
|
|
166
|
+
try:
|
|
167
|
+
parts = shlex.split(command)
|
|
168
|
+
except ValueError:
|
|
169
|
+
parts = command.split()
|
|
170
|
+
cmd = ["exec", name, "-n", namespace, "--"] + parts
|
|
171
|
+
if self._ssh:
|
|
172
|
+
stream = await self._ssh.stream(["kubectl"] + cmd)
|
|
173
|
+
else:
|
|
174
|
+
proc = await asyncio.create_subprocess_exec(
|
|
175
|
+
self._kubectl_path, *cmd,
|
|
176
|
+
stdout=asyncio.subprocess.PIPE,
|
|
177
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
178
|
+
)
|
|
179
|
+
stream = proc.stdout
|
|
180
|
+
while True:
|
|
181
|
+
line = await stream.readline()
|
|
182
|
+
if not line:
|
|
183
|
+
break
|
|
184
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
185
|
+
|
|
186
|
+
async def close(self) -> None:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def _parse_id(self, container_id: str) -> Tuple[str, str]:
|
|
190
|
+
if "/" in container_id:
|
|
191
|
+
namespace, name = container_id.split("/", 1)
|
|
192
|
+
return namespace, name
|
|
193
|
+
return "default", container_id
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _ports(containers: list[dict]) -> str:
|
|
197
|
+
ports = []
|
|
198
|
+
for container in containers:
|
|
199
|
+
for port in container.get("ports", []):
|
|
200
|
+
container_port = port.get("containerPort")
|
|
201
|
+
protocol = port.get("protocol", "TCP")
|
|
202
|
+
if container_port:
|
|
203
|
+
ports.append(f"{container_port}/{protocol.lower()}")
|
|
204
|
+
return ", ".join(ports)
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _age(timestamp: str) -> str:
|
|
208
|
+
if not timestamp:
|
|
209
|
+
return ""
|
|
210
|
+
try:
|
|
211
|
+
created = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
212
|
+
except ValueError:
|
|
213
|
+
return timestamp
|
|
214
|
+
delta = datetime.now(timezone.utc) - created
|
|
215
|
+
seconds = int(delta.total_seconds())
|
|
216
|
+
if seconds < 60:
|
|
217
|
+
return f"{seconds}s"
|
|
218
|
+
minutes = seconds // 60
|
|
219
|
+
if minutes < 60:
|
|
220
|
+
return f"{minutes}m"
|
|
221
|
+
hours = minutes // 60
|
|
222
|
+
if hours < 24:
|
|
223
|
+
return f"{hours}h"
|
|
224
|
+
return f"{hours // 24}d"
|
|
225
|
+
|
|
226
|
+
async def _run(self, *args: str) -> str:
|
|
227
|
+
if self._ssh:
|
|
228
|
+
return await self._ssh.run(["kubectl"] + list(args))
|
|
229
|
+
proc = await asyncio.create_subprocess_exec(
|
|
230
|
+
self._kubectl_path, *args,
|
|
231
|
+
stdout=asyncio.subprocess.PIPE,
|
|
232
|
+
stderr=asyncio.subprocess.PIPE,
|
|
233
|
+
)
|
|
234
|
+
stdout, stderr = await proc.communicate()
|
|
235
|
+
if proc.returncode != 0:
|
|
236
|
+
raise RuntimeError(
|
|
237
|
+
f"kubectl {' '.join(args)} failed: {stderr.decode()}"
|
|
238
|
+
)
|
|
239
|
+
return stdout.decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
from typing import AsyncIterator, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..remote.ssh import SSHConnection
|
|
9
|
+
from .kubernetes import KubernetesProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenShiftProvider(KubernetesProvider):
|
|
13
|
+
name = "openshift"
|
|
14
|
+
|
|
15
|
+
def __init__(self, ssh: Optional[SSHConnection] = None) -> None:
|
|
16
|
+
super().__init__(ssh=ssh)
|
|
17
|
+
self._oc_path: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
async def is_available(self) -> bool:
|
|
20
|
+
if self._ssh:
|
|
21
|
+
try:
|
|
22
|
+
await self._ssh.run(["oc", "whoami"])
|
|
23
|
+
self._kubectl_path = "oc"
|
|
24
|
+
return True
|
|
25
|
+
except RuntimeError:
|
|
26
|
+
return False
|
|
27
|
+
self._kubectl_path = shutil.which("oc")
|
|
28
|
+
if not self._kubectl_path:
|
|
29
|
+
return False
|
|
30
|
+
proc = await asyncio.create_subprocess_exec(
|
|
31
|
+
self._kubectl_path, "whoami",
|
|
32
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
33
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
34
|
+
)
|
|
35
|
+
code = await proc.wait()
|
|
36
|
+
return code == 0
|
|
37
|
+
|
|
38
|
+
async def list_containers(self) -> List[dict]:
|
|
39
|
+
raw = await self._run(
|
|
40
|
+
"get", "pods", "--all-namespaces", "-o", "json"
|
|
41
|
+
)
|
|
42
|
+
data = json.loads(raw)
|
|
43
|
+
pods = []
|
|
44
|
+
for item in data.get("items", []):
|
|
45
|
+
pods.append({
|
|
46
|
+
"id": f"{item['metadata']['namespace']}/{item['metadata']['name']}",
|
|
47
|
+
"name": item["metadata"]["name"],
|
|
48
|
+
"namespace": item["metadata"]["namespace"],
|
|
49
|
+
"status": item["status"]["phase"],
|
|
50
|
+
"node": item.get("spec", {}).get("nodeName", ""),
|
|
51
|
+
"containers": [
|
|
52
|
+
c["name"] for c in item["spec"]["containers"]
|
|
53
|
+
],
|
|
54
|
+
"raw": item,
|
|
55
|
+
})
|
|
56
|
+
return pods
|
|
57
|
+
|
|
58
|
+
async def stats(self, container_id: str) -> AsyncIterator[dict]:
|
|
59
|
+
namespace, name = self._parse_id(container_id)
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
raw = await self._run(
|
|
63
|
+
"adm", "top", "pod", name, "-n", namespace
|
|
64
|
+
)
|
|
65
|
+
lines = raw.strip().split("\n")
|
|
66
|
+
if len(lines) >= 2:
|
|
67
|
+
parts = lines[1].split()
|
|
68
|
+
if len(parts) >= 3:
|
|
69
|
+
yield {
|
|
70
|
+
"pod": name,
|
|
71
|
+
"namespace": namespace,
|
|
72
|
+
"cpu": parts[1],
|
|
73
|
+
"memory": parts[2],
|
|
74
|
+
}
|
|
75
|
+
except RuntimeError:
|
|
76
|
+
yield {"pod": name, "namespace": namespace, "cpu": "N/A", "memory": "N/A"}
|
|
77
|
+
await asyncio.sleep(2)
|
|
@@ -0,0 +1,158 @@
|
|
|
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 PodmanProvider:
|
|
14
|
+
name = "podman"
|
|
15
|
+
|
|
16
|
+
def __init__(self, ssh: Optional[SSHConnection] = None) -> None:
|
|
17
|
+
self._podman_path: Optional[str] = None
|
|
18
|
+
self._ssh = ssh
|
|
19
|
+
|
|
20
|
+
async def is_available(self) -> bool:
|
|
21
|
+
if self._ssh:
|
|
22
|
+
try:
|
|
23
|
+
await self._ssh.run(["podman", "info"])
|
|
24
|
+
self._podman_path = "podman"
|
|
25
|
+
return True
|
|
26
|
+
except RuntimeError:
|
|
27
|
+
return False
|
|
28
|
+
self._podman_path = shutil.which("podman")
|
|
29
|
+
if not self._podman_path:
|
|
30
|
+
return False
|
|
31
|
+
proc = await asyncio.create_subprocess_exec(
|
|
32
|
+
self._podman_path, "info",
|
|
33
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
34
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
35
|
+
)
|
|
36
|
+
code = await proc.wait()
|
|
37
|
+
return code == 0
|
|
38
|
+
|
|
39
|
+
async def list_containers(self) -> List[ContainerSummary]:
|
|
40
|
+
raw = await self._run("ps", "-a", "--format", "json")
|
|
41
|
+
data = json.loads(raw)
|
|
42
|
+
if isinstance(data, list):
|
|
43
|
+
return data
|
|
44
|
+
return [data] if data else []
|
|
45
|
+
|
|
46
|
+
async def inspect(self, container_id: str) -> ContainerDetails:
|
|
47
|
+
raw = await self._run("inspect", container_id)
|
|
48
|
+
data = json.loads(raw)
|
|
49
|
+
if isinstance(data, list):
|
|
50
|
+
return data[0] if data else {}
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
async def stats(self, container_id: str) -> AsyncIterator[ContainerStats]:
|
|
54
|
+
while True:
|
|
55
|
+
raw = await self._run("stats", "--no-stream", "--format", "json", container_id)
|
|
56
|
+
if raw.strip():
|
|
57
|
+
data = json.loads(raw)
|
|
58
|
+
if isinstance(data, list):
|
|
59
|
+
if data:
|
|
60
|
+
yield data[0]
|
|
61
|
+
else:
|
|
62
|
+
yield data
|
|
63
|
+
await asyncio.sleep(1)
|
|
64
|
+
|
|
65
|
+
async def logs(
|
|
66
|
+
self, container_id: str, tail: int = 100, follow: bool = False
|
|
67
|
+
) -> AsyncIterator[str]:
|
|
68
|
+
cmd = ["logs", "--tail", str(tail)]
|
|
69
|
+
if follow:
|
|
70
|
+
cmd.append("-f")
|
|
71
|
+
cmd.append(container_id)
|
|
72
|
+
|
|
73
|
+
if self._ssh:
|
|
74
|
+
stream = await self._ssh.stream(["podman"] + cmd)
|
|
75
|
+
else:
|
|
76
|
+
proc = await asyncio.create_subprocess_exec(
|
|
77
|
+
self._podman_path, *cmd,
|
|
78
|
+
stdout=asyncio.subprocess.PIPE,
|
|
79
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
80
|
+
)
|
|
81
|
+
stream = proc.stdout
|
|
82
|
+
while True:
|
|
83
|
+
line = await stream.readline()
|
|
84
|
+
if not line:
|
|
85
|
+
break
|
|
86
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
87
|
+
if not follow:
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
async def get_env(self, container_id: str) -> Dict[str, str]:
|
|
91
|
+
details = await self.inspect(container_id)
|
|
92
|
+
env_list: List[str] = (
|
|
93
|
+
details.get("Config", {}).get("Env", [])
|
|
94
|
+
)
|
|
95
|
+
env_dict: Dict[str, str] = {}
|
|
96
|
+
for entry in env_list:
|
|
97
|
+
if "=" in entry:
|
|
98
|
+
key, _, val = entry.partition("=")
|
|
99
|
+
env_dict[key] = val
|
|
100
|
+
return env_dict
|
|
101
|
+
|
|
102
|
+
async def start(self, container_id: str) -> None:
|
|
103
|
+
await self._run("start", container_id)
|
|
104
|
+
|
|
105
|
+
async def stop(self, container_id: str) -> None:
|
|
106
|
+
await self._run("stop", container_id)
|
|
107
|
+
|
|
108
|
+
async def restart(self, container_id: str) -> None:
|
|
109
|
+
await self._run("restart", container_id)
|
|
110
|
+
|
|
111
|
+
async def remove(self, container_id: str, force: bool = False) -> None:
|
|
112
|
+
args = ["rm"]
|
|
113
|
+
if force:
|
|
114
|
+
args.append("-f")
|
|
115
|
+
args.append(container_id)
|
|
116
|
+
await self._run(*args)
|
|
117
|
+
|
|
118
|
+
async def set_restart_policy(self, container_id: str, policy: str) -> None:
|
|
119
|
+
await self._run("update", "--restart", policy, container_id)
|
|
120
|
+
|
|
121
|
+
async def exec_command(self, container_id: str, command: str) -> AsyncIterator[str]:
|
|
122
|
+
try:
|
|
123
|
+
parts = shlex.split(command)
|
|
124
|
+
except ValueError:
|
|
125
|
+
parts = command.split()
|
|
126
|
+
cmd = ["exec", container_id] + parts
|
|
127
|
+
if self._ssh:
|
|
128
|
+
stream = await self._ssh.stream(["podman"] + cmd)
|
|
129
|
+
else:
|
|
130
|
+
proc = await asyncio.create_subprocess_exec(
|
|
131
|
+
self._podman_path, *cmd,
|
|
132
|
+
stdout=asyncio.subprocess.PIPE,
|
|
133
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
134
|
+
)
|
|
135
|
+
stream = proc.stdout
|
|
136
|
+
while True:
|
|
137
|
+
line = await stream.readline()
|
|
138
|
+
if not line:
|
|
139
|
+
break
|
|
140
|
+
yield line.decode("utf-8", errors="replace").rstrip("\n")
|
|
141
|
+
|
|
142
|
+
async def close(self) -> None:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
async def _run(self, *args: str) -> str:
|
|
146
|
+
if self._ssh:
|
|
147
|
+
return await self._ssh.run(["podman"] + list(args))
|
|
148
|
+
proc = await asyncio.create_subprocess_exec(
|
|
149
|
+
self._podman_path, *args,
|
|
150
|
+
stdout=asyncio.subprocess.PIPE,
|
|
151
|
+
stderr=asyncio.subprocess.PIPE,
|
|
152
|
+
)
|
|
153
|
+
stdout, stderr = await proc.communicate()
|
|
154
|
+
if proc.returncode != 0:
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"podman {' '.join(args)} failed: {stderr.decode()}"
|
|
157
|
+
)
|
|
158
|
+
return stdout.decode("utf-8", errors="replace")
|