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