kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
kstlib/ops/container.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Container session runner for production environments.
|
|
2
|
+
|
|
3
|
+
This module provides the ContainerRunner class for managing Podman/Docker
|
|
4
|
+
containers, enabling persistent processes with pseudo-terminal support.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Create named containers with custom images and volumes
|
|
8
|
+
- Attach to running containers (replaces current process)
|
|
9
|
+
- Retrieve container logs with ANSI codes preserved
|
|
10
|
+
- Support for both Podman and Docker runtimes
|
|
11
|
+
- Automatic log volume mounting for post-mortem analysis
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from kstlib.ops import SessionConfig, BackendType
|
|
15
|
+
>>> from kstlib.ops.container import ContainerRunner
|
|
16
|
+
>>> runner = ContainerRunner(runtime="podman") # doctest: +SKIP
|
|
17
|
+
>>> config = SessionConfig( # doctest: +SKIP
|
|
18
|
+
... name="bot",
|
|
19
|
+
... backend=BackendType.CONTAINER,
|
|
20
|
+
... image="bot:latest",
|
|
21
|
+
... volumes=["./data:/app/data"],
|
|
22
|
+
... )
|
|
23
|
+
>>> status = runner.start(config) # doctest: +SKIP
|
|
24
|
+
>>> runner.attach("bot") # Replaces process # doctest: +SKIP
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
from kstlib.ops.exceptions import (
|
|
36
|
+
ContainerRuntimeNotFoundError,
|
|
37
|
+
SessionAttachError,
|
|
38
|
+
SessionExistsError,
|
|
39
|
+
SessionNotFoundError,
|
|
40
|
+
SessionStartError,
|
|
41
|
+
SessionStopError,
|
|
42
|
+
)
|
|
43
|
+
from kstlib.ops.models import BackendType, SessionConfig, SessionState, SessionStatus
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Sequence
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ContainerRunner:
|
|
52
|
+
"""Container runner for Podman/Docker containers.
|
|
53
|
+
|
|
54
|
+
Manages containers for running persistent processes with
|
|
55
|
+
pseudo-terminal support for TUI applications.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
runtime: Container runtime to use ("podman" or "docker").
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
runtime: The container runtime name.
|
|
62
|
+
binary: The validated runtime binary path.
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
>>> runner = ContainerRunner() # Uses podman by default # doctest: +SKIP
|
|
66
|
+
>>> runner = ContainerRunner(runtime="docker") # doctest: +SKIP
|
|
67
|
+
>>> config = SessionConfig(
|
|
68
|
+
... name="app",
|
|
69
|
+
... backend=BackendType.CONTAINER,
|
|
70
|
+
... image="python:3.10-slim",
|
|
71
|
+
... )
|
|
72
|
+
>>> status = runner.start(config) # doctest: +SKIP
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, runtime: str | None = None) -> None:
|
|
76
|
+
"""Initialize ContainerRunner.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
runtime: Container runtime ("podman", "docker", or None for auto-detect).
|
|
80
|
+
Auto-detection tries podman first, then docker.
|
|
81
|
+
"""
|
|
82
|
+
if runtime is None:
|
|
83
|
+
# Auto-detect: try podman first, then docker
|
|
84
|
+
if shutil.which("podman"):
|
|
85
|
+
self._runtime = "podman"
|
|
86
|
+
elif shutil.which("docker"):
|
|
87
|
+
self._runtime = "docker"
|
|
88
|
+
else:
|
|
89
|
+
self._runtime = "podman" # Will fail with clear error message
|
|
90
|
+
else:
|
|
91
|
+
self._runtime = runtime
|
|
92
|
+
self._binary_path: str | None = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def runtime(self) -> str:
|
|
96
|
+
"""Return the configured runtime name."""
|
|
97
|
+
return self._runtime
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def binary(self) -> str:
|
|
101
|
+
"""Return validated container runtime binary path.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ContainerRuntimeNotFoundError: If runtime is not installed.
|
|
105
|
+
"""
|
|
106
|
+
if self._binary_path is None:
|
|
107
|
+
path = shutil.which(self._runtime)
|
|
108
|
+
if path is None:
|
|
109
|
+
raise ContainerRuntimeNotFoundError(
|
|
110
|
+
f"Container runtime '{self._runtime}' not found in PATH. "
|
|
111
|
+
f"Install {self._runtime}: https://{'podman.io' if self._runtime == 'podman' else 'docker.com'}"
|
|
112
|
+
)
|
|
113
|
+
self._binary_path = path
|
|
114
|
+
return self._binary_path
|
|
115
|
+
|
|
116
|
+
def _run(
|
|
117
|
+
self,
|
|
118
|
+
args: Sequence[str],
|
|
119
|
+
*,
|
|
120
|
+
check: bool = False,
|
|
121
|
+
) -> subprocess.CompletedProcess[str]:
|
|
122
|
+
"""Run a container command.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
args: Command arguments (without binary name).
|
|
126
|
+
check: Whether to raise on non-zero exit.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
CompletedProcess with stdout/stderr.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ContainerRuntimeNotFoundError: If runtime is not installed.
|
|
133
|
+
"""
|
|
134
|
+
cmd = [self.binary, *args]
|
|
135
|
+
logger.debug("Running: %s", " ".join(cmd))
|
|
136
|
+
return subprocess.run( # noqa: S603
|
|
137
|
+
cmd,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
check=check,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _inspect(self, name: str) -> dict[str, Any] | None:
|
|
144
|
+
"""Inspect a container and return its metadata.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
name: Container name.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Container metadata dict or None if not found.
|
|
151
|
+
"""
|
|
152
|
+
result = self._run(["inspect", name, "--format", "json"])
|
|
153
|
+
if result.returncode != 0:
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(result.stdout)
|
|
157
|
+
if isinstance(data, list) and len(data) > 0:
|
|
158
|
+
return data[0] # type: ignore[no-any-return]
|
|
159
|
+
return None
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _restart_stopped(self, name: str) -> SessionStatus:
|
|
164
|
+
"""Restart a stopped container.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
name: Container name to restart.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
SessionStatus after restart.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
SessionStartError: If restart failed.
|
|
174
|
+
"""
|
|
175
|
+
logger.info("Restarting stopped container: %s", name)
|
|
176
|
+
result = self._run(["start", name])
|
|
177
|
+
if result.returncode != 0:
|
|
178
|
+
raise SessionStartError(
|
|
179
|
+
name,
|
|
180
|
+
"container",
|
|
181
|
+
f"Failed to restart: {result.stderr}",
|
|
182
|
+
)
|
|
183
|
+
return self.status(name)
|
|
184
|
+
|
|
185
|
+
def _build_run_args(self, config: SessionConfig) -> list[str]:
|
|
186
|
+
"""Build command arguments for container run.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
config: Session configuration.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
List of command arguments.
|
|
193
|
+
"""
|
|
194
|
+
args = [
|
|
195
|
+
"run",
|
|
196
|
+
"-d", # Detached
|
|
197
|
+
"--name",
|
|
198
|
+
config.name,
|
|
199
|
+
"-it", # Interactive with pseudo-terminal (for TUI support)
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
# Working directory
|
|
203
|
+
if config.working_dir:
|
|
204
|
+
args.extend(["-w", config.working_dir])
|
|
205
|
+
|
|
206
|
+
# Environment variables
|
|
207
|
+
for key, value in config.env.items():
|
|
208
|
+
args.extend(["-e", f"{key}={value}"])
|
|
209
|
+
|
|
210
|
+
# Volumes
|
|
211
|
+
for volume in config.volumes:
|
|
212
|
+
args.extend(["-v", volume])
|
|
213
|
+
|
|
214
|
+
# Log volume (auto-mount for post-mortem analysis)
|
|
215
|
+
if config.log_volume:
|
|
216
|
+
args.extend(["-v", config.log_volume])
|
|
217
|
+
|
|
218
|
+
# Port mappings
|
|
219
|
+
for port in config.ports:
|
|
220
|
+
args.extend(["-p", port])
|
|
221
|
+
|
|
222
|
+
# Image
|
|
223
|
+
args.append(config.image) # type: ignore[arg-type]
|
|
224
|
+
|
|
225
|
+
# Command (optional)
|
|
226
|
+
if config.command:
|
|
227
|
+
args.extend(config.command.split())
|
|
228
|
+
|
|
229
|
+
return args
|
|
230
|
+
|
|
231
|
+
def start(self, config: SessionConfig) -> SessionStatus:
|
|
232
|
+
"""Create and start a new container.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
config: Session configuration with image and options.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
SessionStatus with state information.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
SessionExistsError: If container already exists and is running.
|
|
242
|
+
SessionStartError: If container failed to start.
|
|
243
|
+
ContainerRuntimeNotFoundError: If runtime is not installed.
|
|
244
|
+
"""
|
|
245
|
+
# Check if container already exists
|
|
246
|
+
if self.exists(config.name):
|
|
247
|
+
info = self._inspect(config.name)
|
|
248
|
+
if info and info.get("State", {}).get("Running", False):
|
|
249
|
+
raise SessionExistsError(config.name, "container")
|
|
250
|
+
# Container exists but stopped - restart it
|
|
251
|
+
return self._restart_stopped(config.name)
|
|
252
|
+
|
|
253
|
+
if not config.image:
|
|
254
|
+
raise SessionStartError(
|
|
255
|
+
config.name,
|
|
256
|
+
"container",
|
|
257
|
+
"Container image is required",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
args = self._build_run_args(config)
|
|
261
|
+
result = self._run(args)
|
|
262
|
+
|
|
263
|
+
if result.returncode != 0:
|
|
264
|
+
raise SessionStartError(
|
|
265
|
+
config.name,
|
|
266
|
+
"container",
|
|
267
|
+
result.stderr.strip() or "Unknown error",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
logger.info("Started container: %s", config.name)
|
|
271
|
+
return self.status(config.name)
|
|
272
|
+
|
|
273
|
+
def stop(
|
|
274
|
+
self,
|
|
275
|
+
name: str,
|
|
276
|
+
*,
|
|
277
|
+
graceful: bool = True,
|
|
278
|
+
timeout: int = 10,
|
|
279
|
+
) -> bool:
|
|
280
|
+
"""Stop a running container.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
name: Container name to stop.
|
|
284
|
+
graceful: If True, use stop with timeout. If False, use kill.
|
|
285
|
+
timeout: Seconds to wait for graceful shutdown.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
True if stopped, False if not running.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
SessionNotFoundError: If container doesn't exist.
|
|
292
|
+
SessionStopError: If container couldn't be stopped.
|
|
293
|
+
"""
|
|
294
|
+
if not self.exists(name):
|
|
295
|
+
raise SessionNotFoundError(name, "container")
|
|
296
|
+
|
|
297
|
+
cmd = ["stop", "-t", str(timeout), name] if graceful else ["kill", name]
|
|
298
|
+
result = self._run(cmd)
|
|
299
|
+
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
# Check if container already stopped
|
|
302
|
+
info = self._inspect(name)
|
|
303
|
+
if info and not info.get("State", {}).get("Running", False):
|
|
304
|
+
logger.info("Container already stopped: %s", name)
|
|
305
|
+
return True
|
|
306
|
+
raise SessionStopError(
|
|
307
|
+
name,
|
|
308
|
+
"container",
|
|
309
|
+
result.stderr.strip() or "Unknown error",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Remove the container after stopping
|
|
313
|
+
self._run(["rm", name])
|
|
314
|
+
|
|
315
|
+
logger.info("Stopped container: %s", name)
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def attach(self, name: str) -> None:
|
|
319
|
+
"""Attach to a running container.
|
|
320
|
+
|
|
321
|
+
This method replaces the current process with container attach.
|
|
322
|
+
It does not return on success.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
name: Container name to attach to.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
SessionNotFoundError: If container doesn't exist.
|
|
329
|
+
SessionAttachError: If attachment failed.
|
|
330
|
+
|
|
331
|
+
Note:
|
|
332
|
+
Use Ctrl+P Ctrl+Q to detach from the container.
|
|
333
|
+
"""
|
|
334
|
+
if not self.exists(name):
|
|
335
|
+
raise SessionNotFoundError(name, "container")
|
|
336
|
+
|
|
337
|
+
# Check if running
|
|
338
|
+
info = self._inspect(name)
|
|
339
|
+
if info and not info.get("State", {}).get("Running", False):
|
|
340
|
+
raise SessionAttachError(
|
|
341
|
+
name,
|
|
342
|
+
"container",
|
|
343
|
+
"Container is not running",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
binary = self.binary
|
|
347
|
+
logger.info("Attaching to container: %s", name)
|
|
348
|
+
|
|
349
|
+
# Attach to container (interactive)
|
|
350
|
+
# On Windows, os.execvp doesn't work well with paths containing spaces
|
|
351
|
+
# Use subprocess.run which handles this correctly
|
|
352
|
+
try:
|
|
353
|
+
result = subprocess.run( # noqa: S603
|
|
354
|
+
[binary, "attach", name],
|
|
355
|
+
check=False,
|
|
356
|
+
)
|
|
357
|
+
# Docker returns exit code 1 when detaching with Ctrl+P Ctrl+Q
|
|
358
|
+
# This is normal behavior, not an error
|
|
359
|
+
if result.returncode not in (0, 1):
|
|
360
|
+
raise SessionAttachError(
|
|
361
|
+
name,
|
|
362
|
+
"container",
|
|
363
|
+
f"Attach exited with code {result.returncode}",
|
|
364
|
+
)
|
|
365
|
+
except OSError as e:
|
|
366
|
+
raise SessionAttachError(name, "container", str(e)) from e
|
|
367
|
+
|
|
368
|
+
def status(self, name: str) -> SessionStatus:
|
|
369
|
+
"""Get status of a container.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
name: Container name to query.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
SessionStatus with current state.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
SessionNotFoundError: If container doesn't exist.
|
|
379
|
+
"""
|
|
380
|
+
info = self._inspect(name)
|
|
381
|
+
if info is None:
|
|
382
|
+
raise SessionNotFoundError(name, "container")
|
|
383
|
+
|
|
384
|
+
state_info = info.get("State", {})
|
|
385
|
+
running = state_info.get("Running", False)
|
|
386
|
+
exited = state_info.get("Status") == "exited"
|
|
387
|
+
|
|
388
|
+
if running:
|
|
389
|
+
state = SessionState.RUNNING
|
|
390
|
+
elif exited:
|
|
391
|
+
state = SessionState.EXITED
|
|
392
|
+
else:
|
|
393
|
+
state = SessionState.STOPPED
|
|
394
|
+
|
|
395
|
+
# Get PID
|
|
396
|
+
pid = state_info.get("Pid")
|
|
397
|
+
if pid == 0:
|
|
398
|
+
pid = None
|
|
399
|
+
|
|
400
|
+
# Get image name
|
|
401
|
+
image = info.get("Config", {}).get("Image") or info.get("Image", "")
|
|
402
|
+
|
|
403
|
+
# Get created timestamp
|
|
404
|
+
created = info.get("Created", "")
|
|
405
|
+
|
|
406
|
+
# Get exit code if exited
|
|
407
|
+
exit_code = None
|
|
408
|
+
if exited:
|
|
409
|
+
exit_code = state_info.get("ExitCode")
|
|
410
|
+
|
|
411
|
+
return SessionStatus(
|
|
412
|
+
name=name,
|
|
413
|
+
state=state,
|
|
414
|
+
backend=BackendType.CONTAINER,
|
|
415
|
+
pid=pid,
|
|
416
|
+
created_at=created,
|
|
417
|
+
image=image,
|
|
418
|
+
exit_code=exit_code,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def logs(self, name: str, lines: int = 100) -> str:
|
|
422
|
+
"""Retrieve recent logs from a container.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
name: Container name to get logs from.
|
|
426
|
+
lines: Number of lines to retrieve.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
String with log output (ANSI codes preserved).
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
SessionNotFoundError: If container doesn't exist.
|
|
433
|
+
"""
|
|
434
|
+
if not self.exists(name):
|
|
435
|
+
raise SessionNotFoundError(name, "container")
|
|
436
|
+
|
|
437
|
+
result = self._run(["logs", "--tail", str(lines), name])
|
|
438
|
+
|
|
439
|
+
# Combine stdout and stderr (container logs may go to either)
|
|
440
|
+
return result.stdout + result.stderr
|
|
441
|
+
|
|
442
|
+
def exists(self, name: str) -> bool:
|
|
443
|
+
"""Check if a container with the given name exists.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
name: Container name to check.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
True if container exists, False otherwise.
|
|
450
|
+
"""
|
|
451
|
+
return self._inspect(name) is not None
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def _parse_container_json(raw: str) -> list[dict[str, Any]]:
|
|
455
|
+
"""Parse container JSON output into a list of dicts.
|
|
456
|
+
|
|
457
|
+
Podman may return a single JSON array or one JSON object per line
|
|
458
|
+
depending on version.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
raw: Raw stdout from ``ps --format json``.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
List of parsed container dicts.
|
|
465
|
+
"""
|
|
466
|
+
try:
|
|
467
|
+
parsed = json.loads(raw)
|
|
468
|
+
except json.JSONDecodeError:
|
|
469
|
+
parsed = None
|
|
470
|
+
|
|
471
|
+
if isinstance(parsed, list):
|
|
472
|
+
return [item for item in parsed if isinstance(item, dict)]
|
|
473
|
+
|
|
474
|
+
items: list[dict[str, Any]] = []
|
|
475
|
+
for line in raw.split("\n"):
|
|
476
|
+
if not line:
|
|
477
|
+
continue
|
|
478
|
+
try:
|
|
479
|
+
obj = json.loads(line)
|
|
480
|
+
if isinstance(obj, dict):
|
|
481
|
+
items.append(obj)
|
|
482
|
+
except json.JSONDecodeError:
|
|
483
|
+
continue
|
|
484
|
+
return items
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _container_to_status(data: dict[str, Any]) -> SessionStatus | None:
|
|
488
|
+
"""Convert a single container dict to a SessionStatus.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
data: Parsed container JSON dict.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
SessionStatus or None if the data is unparseable.
|
|
495
|
+
"""
|
|
496
|
+
try:
|
|
497
|
+
name = data.get("Names") or data.get("Name", "")
|
|
498
|
+
if isinstance(name, list):
|
|
499
|
+
name = name[0] if name else ""
|
|
500
|
+
|
|
501
|
+
state_str = data.get("State", "").lower()
|
|
502
|
+
if state_str == "running":
|
|
503
|
+
state = SessionState.RUNNING
|
|
504
|
+
elif state_str in ("exited", "stopped"):
|
|
505
|
+
state = SessionState.EXITED
|
|
506
|
+
else:
|
|
507
|
+
state = SessionState.UNKNOWN
|
|
508
|
+
|
|
509
|
+
return SessionStatus(
|
|
510
|
+
name=name,
|
|
511
|
+
state=state,
|
|
512
|
+
backend=BackendType.CONTAINER,
|
|
513
|
+
image=data.get("Image", ""),
|
|
514
|
+
created_at=data.get("CreatedAt", ""),
|
|
515
|
+
)
|
|
516
|
+
except (KeyError, TypeError, AttributeError):
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
def list_sessions(self) -> list[SessionStatus]:
|
|
520
|
+
"""List all containers.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
List of SessionStatus for all containers.
|
|
524
|
+
"""
|
|
525
|
+
result = self._run(["ps", "-a", "--format", "json"])
|
|
526
|
+
|
|
527
|
+
if result.returncode != 0:
|
|
528
|
+
return []
|
|
529
|
+
|
|
530
|
+
raw = result.stdout.strip()
|
|
531
|
+
if not raw:
|
|
532
|
+
return []
|
|
533
|
+
|
|
534
|
+
items = self._parse_container_json(raw)
|
|
535
|
+
sessions: list[SessionStatus] = []
|
|
536
|
+
for data in items:
|
|
537
|
+
status = self._container_to_status(data)
|
|
538
|
+
if status is not None:
|
|
539
|
+
sessions.append(status)
|
|
540
|
+
|
|
541
|
+
return sessions
|
|
542
|
+
|
|
543
|
+
def exec(
|
|
544
|
+
self,
|
|
545
|
+
name: str,
|
|
546
|
+
command: str,
|
|
547
|
+
*,
|
|
548
|
+
interactive: bool = False,
|
|
549
|
+
) -> subprocess.CompletedProcess[str]:
|
|
550
|
+
"""Execute a command in a running container.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
name: Container name.
|
|
554
|
+
command: Command to execute.
|
|
555
|
+
interactive: If True, use -it flags.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
CompletedProcess with stdout/stderr.
|
|
559
|
+
|
|
560
|
+
Raises:
|
|
561
|
+
SessionNotFoundError: If container doesn't exist.
|
|
562
|
+
"""
|
|
563
|
+
if not self.exists(name):
|
|
564
|
+
raise SessionNotFoundError(name, "container")
|
|
565
|
+
|
|
566
|
+
args = ["exec"]
|
|
567
|
+
if interactive:
|
|
568
|
+
args.append("-it")
|
|
569
|
+
args.append(name)
|
|
570
|
+
args.extend(command.split())
|
|
571
|
+
|
|
572
|
+
return self._run(args)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
__all__ = [
|
|
576
|
+
"ContainerRunner",
|
|
577
|
+
]
|