kstlib 0.0.1a0__py3-none-any.whl → 1.0.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.
- 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.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.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.0.dist-info}/top_level.txt +0 -0
kstlib/ops/exceptions.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Specialized exceptions raised by the kstlib.ops module.
|
|
2
|
+
|
|
3
|
+
Exception hierarchy::
|
|
4
|
+
|
|
5
|
+
KstlibError
|
|
6
|
+
OpsError (base for all ops errors)
|
|
7
|
+
BackendNotFoundError (binary not in PATH)
|
|
8
|
+
TmuxNotFoundError
|
|
9
|
+
ContainerRuntimeNotFoundError
|
|
10
|
+
SessionError (session-related errors)
|
|
11
|
+
SessionExistsError
|
|
12
|
+
SessionNotFoundError
|
|
13
|
+
SessionStartError
|
|
14
|
+
SessionAttachError
|
|
15
|
+
SessionStopError
|
|
16
|
+
SessionAmbiguousError
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from kstlib.config.exceptions import KstlibError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpsError(KstlibError):
|
|
25
|
+
"""Base exception for all ops module errors.
|
|
26
|
+
|
|
27
|
+
All ops-specific exceptions inherit from this class,
|
|
28
|
+
allowing for easy catching of any ops error.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ============================================================================
|
|
33
|
+
# Backend errors (binary not found)
|
|
34
|
+
# ============================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BackendNotFoundError(OpsError, FileNotFoundError):
|
|
38
|
+
"""Backend binary (tmux, podman, docker) not found in PATH.
|
|
39
|
+
|
|
40
|
+
Raised when the required backend binary is not installed or not
|
|
41
|
+
accessible from the current PATH.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TmuxNotFoundError(BackendNotFoundError):
|
|
46
|
+
"""tmux binary not found in PATH.
|
|
47
|
+
|
|
48
|
+
Install tmux to use the tmux backend:
|
|
49
|
+
- macOS: brew install tmux
|
|
50
|
+
- Ubuntu/Debian: apt install tmux
|
|
51
|
+
- Windows: Use WSL2 with tmux installed
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ContainerRuntimeNotFoundError(BackendNotFoundError):
|
|
56
|
+
"""Container runtime (podman or docker) not found in PATH.
|
|
57
|
+
|
|
58
|
+
Install podman or docker to use the container backend:
|
|
59
|
+
- Podman: https://podman.io/getting-started/installation
|
|
60
|
+
- Docker: https://docs.docker.com/get-docker/
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Session errors
|
|
66
|
+
# ============================================================================
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SessionError(OpsError):
|
|
70
|
+
"""Base exception for session-related errors.
|
|
71
|
+
|
|
72
|
+
All session operation exceptions inherit from this class.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SessionExistsError(SessionError):
|
|
77
|
+
"""Session or container with this name already exists.
|
|
78
|
+
|
|
79
|
+
Raised when attempting to create a session with a name that is
|
|
80
|
+
already in use by another session or container.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, name: str, backend: str) -> None:
|
|
84
|
+
"""Initialize SessionExistsError.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
name: The session name that already exists.
|
|
88
|
+
backend: The backend type (tmux, container).
|
|
89
|
+
"""
|
|
90
|
+
super().__init__(f"{backend} session '{name}' already exists")
|
|
91
|
+
self.name = name
|
|
92
|
+
self.backend = backend
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SessionNotFoundError(SessionError):
|
|
96
|
+
"""Session or container not found.
|
|
97
|
+
|
|
98
|
+
Raised when attempting to access a session that does not exist.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, name: str, backend: str) -> None:
|
|
102
|
+
"""Initialize SessionNotFoundError.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
name: The session name that was not found.
|
|
106
|
+
backend: The backend type (tmux, container).
|
|
107
|
+
"""
|
|
108
|
+
super().__init__(f"{backend} session '{name}' not found")
|
|
109
|
+
self.name = name
|
|
110
|
+
self.backend = backend
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SessionStartError(SessionError):
|
|
114
|
+
"""Failed to start session or container.
|
|
115
|
+
|
|
116
|
+
Raised when the backend command to create a new session fails.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, name: str, backend: str, reason: str) -> None:
|
|
120
|
+
"""Initialize SessionStartError.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
name: The session name that failed to start.
|
|
124
|
+
backend: The backend type (tmux, container).
|
|
125
|
+
reason: The reason for the failure.
|
|
126
|
+
"""
|
|
127
|
+
super().__init__(f"Failed to start {backend} session '{name}': {reason}")
|
|
128
|
+
self.name = name
|
|
129
|
+
self.backend = backend
|
|
130
|
+
self.reason = reason
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SessionAttachError(SessionError):
|
|
134
|
+
"""Failed to attach to session or container.
|
|
135
|
+
|
|
136
|
+
Raised when the backend command to attach to a session fails.
|
|
137
|
+
This can happen if the session is not running or if the terminal
|
|
138
|
+
is not interactive.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, name: str, backend: str, reason: str) -> None:
|
|
142
|
+
"""Initialize SessionAttachError.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: The session name that failed to attach.
|
|
146
|
+
backend: The backend type (tmux, container).
|
|
147
|
+
reason: The reason for the failure.
|
|
148
|
+
"""
|
|
149
|
+
super().__init__(f"Failed to attach to {backend} session '{name}': {reason}")
|
|
150
|
+
self.name = name
|
|
151
|
+
self.backend = backend
|
|
152
|
+
self.reason = reason
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SessionStopError(SessionError):
|
|
156
|
+
"""Failed to stop session or container.
|
|
157
|
+
|
|
158
|
+
Raised when the backend command to stop a session fails.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, name: str, backend: str, reason: str) -> None:
|
|
162
|
+
"""Initialize SessionStopError.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
name: The session name that failed to stop.
|
|
166
|
+
backend: The backend type (tmux, container).
|
|
167
|
+
reason: The reason for the failure.
|
|
168
|
+
"""
|
|
169
|
+
super().__init__(f"Failed to stop {backend} session '{name}': {reason}")
|
|
170
|
+
self.name = name
|
|
171
|
+
self.backend = backend
|
|
172
|
+
self.reason = reason
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SessionAmbiguousError(SessionError):
|
|
176
|
+
"""Session exists in multiple backends.
|
|
177
|
+
|
|
178
|
+
Raised when auto-detection finds a session in both tmux and container
|
|
179
|
+
backends, requiring explicit backend specification.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, name: str, backends: list[str]) -> None:
|
|
183
|
+
"""Initialize SessionAmbiguousError.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
name: The session name that exists in multiple backends.
|
|
187
|
+
backends: List of backend names where the session was found.
|
|
188
|
+
"""
|
|
189
|
+
backend_list = ", ".join(backends)
|
|
190
|
+
super().__init__(
|
|
191
|
+
f"Session '{name}' found in multiple backends: {backend_list}. Use --backend to specify which one."
|
|
192
|
+
)
|
|
193
|
+
self.name = name
|
|
194
|
+
self.backends = backends
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
__all__ = [
|
|
198
|
+
"BackendNotFoundError",
|
|
199
|
+
"ContainerRuntimeNotFoundError",
|
|
200
|
+
"OpsError",
|
|
201
|
+
"SessionAmbiguousError",
|
|
202
|
+
"SessionAttachError",
|
|
203
|
+
"SessionError",
|
|
204
|
+
"SessionExistsError",
|
|
205
|
+
"SessionNotFoundError",
|
|
206
|
+
"SessionStartError",
|
|
207
|
+
"SessionStopError",
|
|
208
|
+
"TmuxNotFoundError",
|
|
209
|
+
]
|
kstlib/ops/manager.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Session manager facade for unified backend access.
|
|
2
|
+
|
|
3
|
+
This module provides the SessionManager class, a config-driven facade
|
|
4
|
+
that abstracts the underlying backend (tmux or container) and provides
|
|
5
|
+
a unified interface for session management.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Automatic backend selection based on configuration
|
|
9
|
+
- Config-driven session creation from kstlib.conf.yml
|
|
10
|
+
- Unified API for start, stop, attach, status, and logs
|
|
11
|
+
- Support for both tmux and container backends
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from kstlib.ops import SessionManager
|
|
15
|
+
>>> # Local dev with tmux
|
|
16
|
+
>>> session = SessionManager("dev", backend="tmux")
|
|
17
|
+
>>> session.start("python -m app") # doctest: +SKIP
|
|
18
|
+
>>> session.attach() # doctest: +SKIP
|
|
19
|
+
|
|
20
|
+
>>> # From config file
|
|
21
|
+
>>> session = SessionManager.from_config("astro") # doctest: +SKIP
|
|
22
|
+
>>> session.start() # doctest: +SKIP
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
from kstlib.ops.container import ContainerRunner
|
|
31
|
+
from kstlib.ops.exceptions import (
|
|
32
|
+
ContainerRuntimeNotFoundError,
|
|
33
|
+
OpsError,
|
|
34
|
+
SessionAmbiguousError,
|
|
35
|
+
TmuxNotFoundError,
|
|
36
|
+
)
|
|
37
|
+
from kstlib.ops.models import BackendType, SessionConfig, SessionState, SessionStatus
|
|
38
|
+
from kstlib.ops.tmux import TmuxRunner
|
|
39
|
+
from kstlib.ops.validators import validate_session_name
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from kstlib.ops.base import AbstractRunner
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def auto_detect_backend(name: str) -> BackendType | None:
|
|
48
|
+
"""Auto-detect which backend a session exists in.
|
|
49
|
+
|
|
50
|
+
Checks both tmux and container backends to find where a session with
|
|
51
|
+
the given name exists. Skips backends that are not available (binary
|
|
52
|
+
not found).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
name: Session name to search for.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
BackendType if found in exactly one backend, None if not found.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
SessionAmbiguousError: If session exists in multiple backends.
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
>>> # Session exists in tmux only
|
|
65
|
+
>>> backend = auto_detect_backend("mybot") # doctest: +SKIP
|
|
66
|
+
>>> backend == BackendType.TMUX # doctest: +SKIP
|
|
67
|
+
True
|
|
68
|
+
|
|
69
|
+
>>> # Session not found
|
|
70
|
+
>>> auto_detect_backend("nonexistent") is None # doctest: +SKIP
|
|
71
|
+
True
|
|
72
|
+
"""
|
|
73
|
+
found_in: list[str] = []
|
|
74
|
+
|
|
75
|
+
# Check tmux backend (skip if not installed)
|
|
76
|
+
try:
|
|
77
|
+
tmux_runner = TmuxRunner()
|
|
78
|
+
if tmux_runner.exists(name):
|
|
79
|
+
found_in.append("tmux")
|
|
80
|
+
except TmuxNotFoundError:
|
|
81
|
+
logger.debug("tmux not found, skipping tmux backend check")
|
|
82
|
+
|
|
83
|
+
# Check container backend (skip if not installed)
|
|
84
|
+
try:
|
|
85
|
+
container_runner = ContainerRunner()
|
|
86
|
+
if container_runner.exists(name):
|
|
87
|
+
found_in.append("container")
|
|
88
|
+
except ContainerRuntimeNotFoundError:
|
|
89
|
+
logger.debug("container runtime not found, skipping container backend check")
|
|
90
|
+
|
|
91
|
+
# Return based on findings
|
|
92
|
+
if len(found_in) == 0:
|
|
93
|
+
return None
|
|
94
|
+
if len(found_in) == 1:
|
|
95
|
+
return BackendType(found_in[0])
|
|
96
|
+
# Found in multiple backends
|
|
97
|
+
raise SessionAmbiguousError(name, found_in)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SessionConfigError(OpsError):
|
|
101
|
+
"""Configuration error for session management.
|
|
102
|
+
|
|
103
|
+
Raised when session configuration is invalid or missing required fields.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SessionManager:
|
|
108
|
+
"""Config-driven session manager with backend abstraction.
|
|
109
|
+
|
|
110
|
+
Provides a unified interface for managing sessions across different
|
|
111
|
+
backends (tmux, container). The backend can be specified directly
|
|
112
|
+
or loaded from kstlib.conf.yml configuration.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
name: Unique session name.
|
|
116
|
+
backend: Backend type ("tmux" or "container").
|
|
117
|
+
**kwargs: Backend-specific options (image, volumes, ports, etc.).
|
|
118
|
+
|
|
119
|
+
Attributes:
|
|
120
|
+
name: The session name.
|
|
121
|
+
backend: The backend type being used.
|
|
122
|
+
config: The full session configuration.
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
>>> # Direct instantiation with tmux
|
|
126
|
+
>>> session = SessionManager("dev", backend="tmux")
|
|
127
|
+
>>> session.start("python app.py") # doctest: +SKIP
|
|
128
|
+
>>> session.attach() # doctest: +SKIP
|
|
129
|
+
|
|
130
|
+
>>> # Direct instantiation with container
|
|
131
|
+
>>> session = SessionManager(
|
|
132
|
+
... "prod",
|
|
133
|
+
... backend="container",
|
|
134
|
+
... image="app:latest",
|
|
135
|
+
... volumes=["./data:/app/data"],
|
|
136
|
+
... )
|
|
137
|
+
>>> session.start() # doctest: +SKIP
|
|
138
|
+
|
|
139
|
+
>>> # From config file (recommended)
|
|
140
|
+
>>> session = SessionManager.from_config("astro") # doctest: +SKIP
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
name: str,
|
|
146
|
+
*,
|
|
147
|
+
backend: str | BackendType = BackendType.TMUX,
|
|
148
|
+
**kwargs: Any,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Initialize SessionManager.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
name: Unique session name.
|
|
154
|
+
backend: Backend type ("tmux" or "container").
|
|
155
|
+
**kwargs: Backend-specific options.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
SessionConfigError: If configuration is invalid.
|
|
159
|
+
"""
|
|
160
|
+
# Validate session name early for better error messages
|
|
161
|
+
try:
|
|
162
|
+
validate_session_name(name)
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
raise SessionConfigError(str(e)) from None
|
|
165
|
+
|
|
166
|
+
self._name = name
|
|
167
|
+
|
|
168
|
+
# Normalize backend type
|
|
169
|
+
if isinstance(backend, BackendType):
|
|
170
|
+
self._backend = backend
|
|
171
|
+
else:
|
|
172
|
+
# Must be a string - convert to BackendType
|
|
173
|
+
try:
|
|
174
|
+
self._backend = BackendType(backend.lower())
|
|
175
|
+
except ValueError:
|
|
176
|
+
raise SessionConfigError(f"Invalid backend '{backend}'. Must be 'tmux' or 'container'.") from None
|
|
177
|
+
|
|
178
|
+
# Build session config (validation happens in SessionConfig.__post_init__)
|
|
179
|
+
try:
|
|
180
|
+
self._config = SessionConfig(
|
|
181
|
+
name=name,
|
|
182
|
+
backend=self._backend,
|
|
183
|
+
command=kwargs.get("command"),
|
|
184
|
+
working_dir=kwargs.get("working_dir"),
|
|
185
|
+
env=kwargs.get("env", {}),
|
|
186
|
+
image=kwargs.get("image"),
|
|
187
|
+
volumes=kwargs.get("volumes", []),
|
|
188
|
+
ports=kwargs.get("ports", []),
|
|
189
|
+
runtime=kwargs.get("runtime"),
|
|
190
|
+
log_volume=kwargs.get("log_volume"),
|
|
191
|
+
)
|
|
192
|
+
except ValueError as e:
|
|
193
|
+
raise SessionConfigError(str(e)) from None
|
|
194
|
+
|
|
195
|
+
# Initialize the appropriate runner
|
|
196
|
+
self._runner: AbstractRunner
|
|
197
|
+
if self._backend == BackendType.TMUX:
|
|
198
|
+
tmux_binary = kwargs.get("tmux_binary", "tmux")
|
|
199
|
+
self._runner = TmuxRunner(binary=tmux_binary)
|
|
200
|
+
else:
|
|
201
|
+
runtime = kwargs.get("runtime") # None = auto-detect
|
|
202
|
+
self._runner = ContainerRunner(runtime=runtime)
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def name(self) -> str:
|
|
206
|
+
"""Return the session name."""
|
|
207
|
+
return self._name
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def backend(self) -> BackendType:
|
|
211
|
+
"""Return the backend type."""
|
|
212
|
+
return self._backend
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def config(self) -> SessionConfig:
|
|
216
|
+
"""Return the session configuration."""
|
|
217
|
+
return self._config
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def from_config(
|
|
221
|
+
cls,
|
|
222
|
+
name: str,
|
|
223
|
+
) -> SessionManager:
|
|
224
|
+
"""Create SessionManager from kstlib configuration.
|
|
225
|
+
|
|
226
|
+
Loads session configuration from kstlib.conf.yml under the
|
|
227
|
+
ops.sessions.{name} key.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
name: Session name to load from config.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
SessionManager configured from the config file.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
SessionConfigError: If session not found in config.
|
|
237
|
+
|
|
238
|
+
Example:
|
|
239
|
+
Config file (kstlib.conf.yml)::
|
|
240
|
+
|
|
241
|
+
ops:
|
|
242
|
+
sessions:
|
|
243
|
+
astro:
|
|
244
|
+
backend: tmux
|
|
245
|
+
command: "python -m astro.bot"
|
|
246
|
+
working_dir: "/opt/astro"
|
|
247
|
+
|
|
248
|
+
Usage::
|
|
249
|
+
|
|
250
|
+
>>> session = SessionManager.from_config("astro") # doctest: +SKIP
|
|
251
|
+
"""
|
|
252
|
+
from kstlib.config import get_config
|
|
253
|
+
|
|
254
|
+
config = get_config()
|
|
255
|
+
|
|
256
|
+
# Navigate to ops.sessions (Box is dynamically typed)
|
|
257
|
+
ops_config: dict[str, Any] = config.get("ops", {}) # type: ignore[no-untyped-call]
|
|
258
|
+
sessions_config: dict[str, Any] = ops_config.get("sessions", {})
|
|
259
|
+
|
|
260
|
+
if name not in sessions_config:
|
|
261
|
+
available = list(sessions_config)
|
|
262
|
+
raise SessionConfigError(f"Session '{name}' not found in config. Available sessions: {available or 'none'}")
|
|
263
|
+
|
|
264
|
+
session_data = sessions_config[name]
|
|
265
|
+
|
|
266
|
+
# Get defaults from ops config
|
|
267
|
+
default_backend = ops_config.get("default_backend", "tmux")
|
|
268
|
+
tmux_binary = ops_config.get("tmux_binary", "tmux")
|
|
269
|
+
container_runtime = ops_config.get("container_runtime") # None = auto-detect
|
|
270
|
+
|
|
271
|
+
# Build kwargs from session config
|
|
272
|
+
backend = session_data.get("backend", default_backend)
|
|
273
|
+
kwargs: dict[str, Any] = {
|
|
274
|
+
"backend": backend,
|
|
275
|
+
"command": session_data.get("command"),
|
|
276
|
+
"working_dir": session_data.get("working_dir"),
|
|
277
|
+
"env": session_data.get("env", {}),
|
|
278
|
+
"image": session_data.get("image"),
|
|
279
|
+
"volumes": session_data.get("volumes", []),
|
|
280
|
+
"ports": session_data.get("ports", []),
|
|
281
|
+
"log_volume": session_data.get("log_volume"),
|
|
282
|
+
"tmux_binary": tmux_binary,
|
|
283
|
+
"runtime": session_data.get("runtime", container_runtime),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return cls(name, **kwargs)
|
|
287
|
+
|
|
288
|
+
def start(
|
|
289
|
+
self,
|
|
290
|
+
command: str | None = None,
|
|
291
|
+
**kwargs: Any,
|
|
292
|
+
) -> SessionStatus:
|
|
293
|
+
"""Start the session.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
command: Command to run (overrides config).
|
|
297
|
+
**kwargs: Additional options to override config.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
SessionStatus with current state.
|
|
301
|
+
|
|
302
|
+
Raises:
|
|
303
|
+
SessionExistsError: If session already exists.
|
|
304
|
+
SessionStartError: If session failed to start.
|
|
305
|
+
"""
|
|
306
|
+
# Build effective config with overrides
|
|
307
|
+
config_dict = {
|
|
308
|
+
"name": self._name,
|
|
309
|
+
"backend": self._backend,
|
|
310
|
+
"command": command or self._config.command,
|
|
311
|
+
"working_dir": kwargs.get("working_dir", self._config.working_dir),
|
|
312
|
+
"env": {**self._config.env, **kwargs.get("env", {})},
|
|
313
|
+
"image": kwargs.get("image", self._config.image),
|
|
314
|
+
"volumes": kwargs.get("volumes", list(self._config.volumes)),
|
|
315
|
+
"ports": kwargs.get("ports", list(self._config.ports)),
|
|
316
|
+
"runtime": kwargs.get("runtime", self._config.runtime),
|
|
317
|
+
"log_volume": kwargs.get("log_volume", self._config.log_volume),
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
effective_config = SessionConfig(**config_dict)
|
|
321
|
+
return self._runner.start(effective_config)
|
|
322
|
+
|
|
323
|
+
def stop(
|
|
324
|
+
self,
|
|
325
|
+
*,
|
|
326
|
+
graceful: bool = True,
|
|
327
|
+
timeout: int = 10,
|
|
328
|
+
) -> bool:
|
|
329
|
+
"""Stop the session.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
graceful: If True, attempt graceful shutdown first.
|
|
333
|
+
timeout: Seconds to wait for graceful shutdown.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
True if stopped successfully.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
SessionNotFoundError: If session does not exist.
|
|
340
|
+
SessionStopError: If session could not be stopped.
|
|
341
|
+
"""
|
|
342
|
+
return self._runner.stop(self._name, graceful=graceful, timeout=timeout)
|
|
343
|
+
|
|
344
|
+
def attach(self) -> None:
|
|
345
|
+
"""Attach to the session.
|
|
346
|
+
|
|
347
|
+
This method replaces the current process. It does not return on success.
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
SessionNotFoundError: If session does not exist.
|
|
351
|
+
SessionAttachError: If attachment failed.
|
|
352
|
+
"""
|
|
353
|
+
self._runner.attach(self._name)
|
|
354
|
+
|
|
355
|
+
def status(self) -> SessionStatus:
|
|
356
|
+
"""Get current session status.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
SessionStatus with current state.
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
SessionNotFoundError: If session does not exist.
|
|
363
|
+
"""
|
|
364
|
+
return self._runner.status(self._name)
|
|
365
|
+
|
|
366
|
+
def logs(self, lines: int = 100) -> str:
|
|
367
|
+
"""Get recent session logs.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
lines: Number of lines to retrieve.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Log output as string (ANSI codes preserved).
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
SessionNotFoundError: If session does not exist.
|
|
377
|
+
"""
|
|
378
|
+
return self._runner.logs(self._name, lines=lines)
|
|
379
|
+
|
|
380
|
+
def exists(self) -> bool:
|
|
381
|
+
"""Check if the session exists.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
True if session exists, False otherwise.
|
|
385
|
+
"""
|
|
386
|
+
return self._runner.exists(self._name)
|
|
387
|
+
|
|
388
|
+
def is_running(self) -> bool:
|
|
389
|
+
"""Check if the session is currently running.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
True if running, False otherwise.
|
|
393
|
+
"""
|
|
394
|
+
if not self.exists():
|
|
395
|
+
return False
|
|
396
|
+
try:
|
|
397
|
+
status = self.status()
|
|
398
|
+
return status.state == SessionState.RUNNING
|
|
399
|
+
except Exception:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
__all__ = [
|
|
404
|
+
"SessionConfigError",
|
|
405
|
+
"SessionManager",
|
|
406
|
+
"auto_detect_backend",
|
|
407
|
+
]
|