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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""List all sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_lib
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from kstlib.cli.common import console
|
|
12
|
+
from kstlib.config import get_config
|
|
13
|
+
from kstlib.ops import (
|
|
14
|
+
ContainerRunner,
|
|
15
|
+
SessionStatus,
|
|
16
|
+
TmuxRunner,
|
|
17
|
+
)
|
|
18
|
+
from kstlib.ops.exceptions import BackendNotFoundError
|
|
19
|
+
from kstlib.ops.models import BackendType, SessionState
|
|
20
|
+
from kstlib.ops.validators import (
|
|
21
|
+
validate_command,
|
|
22
|
+
validate_env,
|
|
23
|
+
validate_image_name,
|
|
24
|
+
validate_ports,
|
|
25
|
+
validate_session_name,
|
|
26
|
+
validate_volumes,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .common import (
|
|
30
|
+
BACKEND_OPTION,
|
|
31
|
+
JSON_OPTION,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Maximum number of config sessions to prevent DoS via large config
|
|
37
|
+
_MAX_CONFIG_SESSIONS = 50
|
|
38
|
+
|
|
39
|
+
_VALID_BACKENDS = {"tmux", "container"}
|
|
40
|
+
|
|
41
|
+
_STATE_STYLES: dict[str, str] = {
|
|
42
|
+
"running": "green",
|
|
43
|
+
"defined": "dim",
|
|
44
|
+
"exited": "yellow",
|
|
45
|
+
"stopped": "yellow",
|
|
46
|
+
"unknown": "red",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_config_sessions() -> dict[str, dict[str, Any]]:
|
|
51
|
+
"""Load session definitions from kstlib configuration.
|
|
52
|
+
|
|
53
|
+
Reads ``ops.sessions`` from the config file and validates
|
|
54
|
+
each entry with deep defense checks.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Validated session configs keyed by session name.
|
|
58
|
+
"""
|
|
59
|
+
config = get_config()
|
|
60
|
+
ops_config: dict[str, Any] = config.get("ops", {}) # type: ignore[no-untyped-call]
|
|
61
|
+
raw_sessions: Any = ops_config.get("sessions", {})
|
|
62
|
+
|
|
63
|
+
# Deep defense: sessions must be a dict
|
|
64
|
+
if not isinstance(raw_sessions, dict):
|
|
65
|
+
log.warning("ops.sessions is not a dict, ignoring config sessions")
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
# Deep defense: limit number of sessions
|
|
69
|
+
if len(raw_sessions) > _MAX_CONFIG_SESSIONS:
|
|
70
|
+
log.warning(
|
|
71
|
+
"ops.sessions has %d entries (max %d), truncating",
|
|
72
|
+
len(raw_sessions),
|
|
73
|
+
_MAX_CONFIG_SESSIONS,
|
|
74
|
+
)
|
|
75
|
+
# Take only first N entries
|
|
76
|
+
raw_sessions = dict(list(raw_sessions.items())[:_MAX_CONFIG_SESSIONS])
|
|
77
|
+
|
|
78
|
+
validated: dict[str, dict[str, Any]] = {}
|
|
79
|
+
for name, data in raw_sessions.items():
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
log.warning("Session '%s' config is not a dict, skipping", name)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
_validate_config_session(name, data)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
log.warning("Invalid config session '%s': %s", name, exc)
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
validated[name] = data
|
|
91
|
+
|
|
92
|
+
return validated
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _validate_config_session(name: str, data: dict[str, Any]) -> None:
|
|
96
|
+
"""Validate a single config session entry.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
name: Session name (YAML key).
|
|
100
|
+
data: Session configuration dict.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If any field is invalid.
|
|
104
|
+
"""
|
|
105
|
+
validate_session_name(name)
|
|
106
|
+
|
|
107
|
+
backend = data.get("backend", "tmux")
|
|
108
|
+
if backend not in _VALID_BACKENDS:
|
|
109
|
+
raise ValueError(f"Invalid backend '{backend}'")
|
|
110
|
+
|
|
111
|
+
image = data.get("image")
|
|
112
|
+
if image is not None:
|
|
113
|
+
validate_image_name(image)
|
|
114
|
+
|
|
115
|
+
command = data.get("command")
|
|
116
|
+
if command is not None:
|
|
117
|
+
validate_command(command)
|
|
118
|
+
|
|
119
|
+
env = data.get("env")
|
|
120
|
+
if env is not None:
|
|
121
|
+
if not isinstance(env, dict):
|
|
122
|
+
raise ValueError("env must be a dict")
|
|
123
|
+
validate_env(env)
|
|
124
|
+
|
|
125
|
+
volumes = data.get("volumes")
|
|
126
|
+
if volumes is not None:
|
|
127
|
+
if not isinstance(volumes, list):
|
|
128
|
+
raise ValueError("volumes must be a list")
|
|
129
|
+
validate_volumes(volumes)
|
|
130
|
+
|
|
131
|
+
ports = data.get("ports")
|
|
132
|
+
if ports is not None:
|
|
133
|
+
if not isinstance(ports, list):
|
|
134
|
+
raise ValueError("ports must be a list")
|
|
135
|
+
validate_ports(ports)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _collect_sessions(backend: str | None) -> list[SessionStatus]:
|
|
139
|
+
"""Collect sessions from runtime backends and config definitions.
|
|
140
|
+
|
|
141
|
+
Merges runtime sessions with config-defined sessions. Config sessions
|
|
142
|
+
that are not currently running appear with state DEFINED.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
backend: Backend to query, or None for all.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
List of SessionStatus from queried backends plus config-defined sessions.
|
|
149
|
+
"""
|
|
150
|
+
sessions: list[SessionStatus] = []
|
|
151
|
+
|
|
152
|
+
# Collect from tmux
|
|
153
|
+
if backend is None or backend == "tmux":
|
|
154
|
+
try:
|
|
155
|
+
tmux = TmuxRunner()
|
|
156
|
+
sessions.extend(tmux.list_sessions())
|
|
157
|
+
except BackendNotFoundError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Collect from container
|
|
161
|
+
if backend is None or backend == "container":
|
|
162
|
+
try:
|
|
163
|
+
container = ContainerRunner()
|
|
164
|
+
sessions.extend(container.list_sessions())
|
|
165
|
+
except BackendNotFoundError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# Merge config-defined sessions
|
|
169
|
+
runtime_names: set[str] = {s.name for s in sessions}
|
|
170
|
+
config_sessions = _load_config_sessions()
|
|
171
|
+
|
|
172
|
+
for name, data in config_sessions.items():
|
|
173
|
+
if name in runtime_names:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
session_backend = data.get("backend", "tmux")
|
|
177
|
+
|
|
178
|
+
# Apply backend filter to config sessions too
|
|
179
|
+
if backend is not None and session_backend != backend:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
backend_type = BackendType(session_backend)
|
|
183
|
+
sessions.append(
|
|
184
|
+
SessionStatus(
|
|
185
|
+
name=name,
|
|
186
|
+
state=SessionState.DEFINED,
|
|
187
|
+
backend=backend_type,
|
|
188
|
+
image=data.get("image"),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return sessions
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def list_sessions(
|
|
196
|
+
backend: str | None = BACKEND_OPTION,
|
|
197
|
+
json: bool = JSON_OPTION,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""List all sessions.
|
|
200
|
+
|
|
201
|
+
Shows all tmux sessions and containers managed by the ops module,
|
|
202
|
+
plus config-defined sessions that have not been started yet.
|
|
203
|
+
Can be filtered by backend type.
|
|
204
|
+
|
|
205
|
+
Examples:
|
|
206
|
+
kstlib ops list
|
|
207
|
+
kstlib ops list --backend tmux
|
|
208
|
+
kstlib ops list --backend container --json
|
|
209
|
+
"""
|
|
210
|
+
sessions = _collect_sessions(backend)
|
|
211
|
+
|
|
212
|
+
if json:
|
|
213
|
+
# JSON output
|
|
214
|
+
data = [
|
|
215
|
+
{
|
|
216
|
+
"name": s.name,
|
|
217
|
+
"state": s.state.value,
|
|
218
|
+
"backend": s.backend.value,
|
|
219
|
+
"pid": s.pid,
|
|
220
|
+
"image": s.image,
|
|
221
|
+
}
|
|
222
|
+
for s in sessions
|
|
223
|
+
]
|
|
224
|
+
console.print(json_lib.dumps(data, indent=2))
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if not sessions:
|
|
228
|
+
console.print("[dim]No sessions found.[/]")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Rich table output
|
|
232
|
+
table = Table(title="Sessions")
|
|
233
|
+
table.add_column("Name", style="cyan")
|
|
234
|
+
table.add_column("State", style="white")
|
|
235
|
+
table.add_column("Backend", style="blue")
|
|
236
|
+
table.add_column("PID", style="dim")
|
|
237
|
+
table.add_column("Image", style="dim")
|
|
238
|
+
|
|
239
|
+
for session in sessions:
|
|
240
|
+
state_style = _STATE_STYLES.get(session.state.value, "yellow")
|
|
241
|
+
table.add_row(
|
|
242
|
+
session.name,
|
|
243
|
+
f"[{state_style}]{session.state.value}[/]",
|
|
244
|
+
session.backend.value,
|
|
245
|
+
str(session.pid) if session.pid else "-",
|
|
246
|
+
session.image or "-",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
console.print(table)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = ["list_sessions"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Show session logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kstlib.cli.common import console, exit_error
|
|
6
|
+
from kstlib.ops.exceptions import OpsError, SessionNotFoundError
|
|
7
|
+
|
|
8
|
+
from .common import (
|
|
9
|
+
BACKEND_OPTION,
|
|
10
|
+
LINES_OPTION,
|
|
11
|
+
SESSION_ARGUMENT,
|
|
12
|
+
get_session_manager,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def logs(
|
|
17
|
+
name: str = SESSION_ARGUMENT,
|
|
18
|
+
backend: str | None = BACKEND_OPTION,
|
|
19
|
+
lines: int = LINES_OPTION,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Show logs from a session.
|
|
22
|
+
|
|
23
|
+
Retrieves and displays recent output from a tmux session or container.
|
|
24
|
+
ANSI color codes are preserved in the output.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
kstlib ops logs dev
|
|
28
|
+
kstlib ops logs prod --lines 50
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
manager = get_session_manager(name, backend=backend)
|
|
32
|
+
|
|
33
|
+
if not manager.exists():
|
|
34
|
+
exit_error(f"Session '{name}' not found.")
|
|
35
|
+
|
|
36
|
+
log_content = manager.logs(lines=lines)
|
|
37
|
+
|
|
38
|
+
if log_content.strip():
|
|
39
|
+
console.print(log_content, markup=False)
|
|
40
|
+
else:
|
|
41
|
+
console.print(f"[dim]No logs available for session '{name}'.[/]")
|
|
42
|
+
|
|
43
|
+
except SessionNotFoundError:
|
|
44
|
+
exit_error(f"Session '{name}' not found.")
|
|
45
|
+
except OpsError as e:
|
|
46
|
+
exit_error(str(e))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["logs"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Start a session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kstlib.cli.common import (
|
|
6
|
+
CommandResult,
|
|
7
|
+
CommandStatus,
|
|
8
|
+
emit_result,
|
|
9
|
+
exit_error,
|
|
10
|
+
)
|
|
11
|
+
from kstlib.ops.exceptions import OpsError, SessionExistsError
|
|
12
|
+
|
|
13
|
+
from .common import (
|
|
14
|
+
BACKEND_OPTION,
|
|
15
|
+
COMMAND_OPTION,
|
|
16
|
+
ENV_OPTION,
|
|
17
|
+
IMAGE_OPTION,
|
|
18
|
+
PORT_OPTION,
|
|
19
|
+
QUIET_OPTION,
|
|
20
|
+
SESSION_ARGUMENT,
|
|
21
|
+
VOLUME_OPTION,
|
|
22
|
+
WORKDIR_OPTION,
|
|
23
|
+
get_session_manager,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def start( # noqa: PLR0913
|
|
28
|
+
name: str = SESSION_ARGUMENT,
|
|
29
|
+
backend: str | None = BACKEND_OPTION,
|
|
30
|
+
command: str | None = COMMAND_OPTION,
|
|
31
|
+
image: str | None = IMAGE_OPTION,
|
|
32
|
+
quiet: bool = QUIET_OPTION,
|
|
33
|
+
working_dir: str | None = WORKDIR_OPTION,
|
|
34
|
+
env: list[str] | None = ENV_OPTION,
|
|
35
|
+
volume: list[str] | None = VOLUME_OPTION,
|
|
36
|
+
port: list[str] | None = PORT_OPTION,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Start a new session.
|
|
39
|
+
|
|
40
|
+
Creates and starts a new tmux session or container with the specified
|
|
41
|
+
configuration. If the session is defined in kstlib.conf.yml, those
|
|
42
|
+
settings will be used as defaults.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
kstlib ops start dev --backend tmux --command "python app.py"
|
|
46
|
+
kstlib ops start prod --backend container --image app:latest
|
|
47
|
+
kstlib ops start astro # Uses config from kstlib.conf.yml
|
|
48
|
+
"""
|
|
49
|
+
# Parse environment variables
|
|
50
|
+
env_dict: dict[str, str] = {}
|
|
51
|
+
if env:
|
|
52
|
+
for item in env:
|
|
53
|
+
if "=" in item:
|
|
54
|
+
key, value = item.split("=", 1)
|
|
55
|
+
env_dict[key] = value
|
|
56
|
+
else:
|
|
57
|
+
exit_error(f"Invalid environment variable format: {item}")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
manager = get_session_manager(
|
|
61
|
+
name,
|
|
62
|
+
backend=backend,
|
|
63
|
+
image=image,
|
|
64
|
+
command=command,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Build kwargs for start
|
|
68
|
+
kwargs: dict[str, str | list[str] | dict[str, str] | None] = {}
|
|
69
|
+
if working_dir:
|
|
70
|
+
kwargs["working_dir"] = working_dir
|
|
71
|
+
if env_dict:
|
|
72
|
+
kwargs["env"] = env_dict
|
|
73
|
+
if volume:
|
|
74
|
+
kwargs["volumes"] = list(volume)
|
|
75
|
+
if port:
|
|
76
|
+
kwargs["ports"] = list(port)
|
|
77
|
+
|
|
78
|
+
status = manager.start(command, **kwargs)
|
|
79
|
+
|
|
80
|
+
result = CommandResult(
|
|
81
|
+
status=CommandStatus.OK,
|
|
82
|
+
message=f"Session '{name}' started ({status.backend.value} backend).",
|
|
83
|
+
payload={
|
|
84
|
+
"name": status.name,
|
|
85
|
+
"state": status.state.value,
|
|
86
|
+
"backend": status.backend.value,
|
|
87
|
+
"pid": status.pid,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
emit_result(result, quiet)
|
|
91
|
+
|
|
92
|
+
except SessionExistsError:
|
|
93
|
+
exit_error(f"Session '{name}' already exists. Use 'kstlib ops stop {name}' first.")
|
|
94
|
+
except OpsError as e:
|
|
95
|
+
exit_error(str(e))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["start"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Show session status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_lib
|
|
6
|
+
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from kstlib.cli.common import console, exit_error
|
|
10
|
+
from kstlib.ops import SessionManager
|
|
11
|
+
from kstlib.ops.exceptions import OpsError, SessionNotFoundError
|
|
12
|
+
from kstlib.ops.models import SessionState, SessionStatus
|
|
13
|
+
from kstlib.utils.formatting import format_timestamp
|
|
14
|
+
|
|
15
|
+
from .common import (
|
|
16
|
+
BACKEND_OPTION,
|
|
17
|
+
JSON_OPTION,
|
|
18
|
+
QUIET_OPTION,
|
|
19
|
+
SESSION_ARGUMENT,
|
|
20
|
+
get_session_manager,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _config_fallback_status(name: str) -> SessionStatus | None:
|
|
25
|
+
"""Try to build a DEFINED status from config for a non-running session.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
name: Session name to look up in config.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
SessionStatus with DEFINED state, or None if not in config.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
manager = SessionManager.from_config(name)
|
|
35
|
+
except OpsError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
return SessionStatus(
|
|
39
|
+
name=name,
|
|
40
|
+
state=SessionState.DEFINED,
|
|
41
|
+
backend=manager.config.backend,
|
|
42
|
+
image=manager.config.image,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_created_at(created_at: str | None) -> str | None:
|
|
47
|
+
"""Format created_at value for display.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
created_at: Raw created_at value (epoch string or ISO format).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Formatted datetime string, or None if no value.
|
|
54
|
+
"""
|
|
55
|
+
if not created_at:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
float(created_at)
|
|
59
|
+
return format_timestamp(created_at)
|
|
60
|
+
except ValueError:
|
|
61
|
+
# Already formatted (ISO from Docker), keep as-is
|
|
62
|
+
return created_at
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def status(
|
|
66
|
+
name: str = SESSION_ARGUMENT,
|
|
67
|
+
backend: str | None = BACKEND_OPTION,
|
|
68
|
+
quiet: bool = QUIET_OPTION,
|
|
69
|
+
json: bool = JSON_OPTION,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Show status of a session.
|
|
72
|
+
|
|
73
|
+
Displays detailed information about a session including its state,
|
|
74
|
+
PID, backend type, and other relevant details.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
kstlib ops status dev
|
|
78
|
+
kstlib ops status prod --json
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
manager = get_session_manager(name, backend=backend)
|
|
82
|
+
|
|
83
|
+
if not manager.exists():
|
|
84
|
+
# Try config fallback for defined-but-not-started sessions
|
|
85
|
+
session_status = _config_fallback_status(name)
|
|
86
|
+
if session_status is None:
|
|
87
|
+
exit_error(f"Session '{name}' not found.")
|
|
88
|
+
else:
|
|
89
|
+
session_status = manager.status()
|
|
90
|
+
|
|
91
|
+
if json:
|
|
92
|
+
# JSON output
|
|
93
|
+
data = {
|
|
94
|
+
"name": session_status.name,
|
|
95
|
+
"state": session_status.state.value,
|
|
96
|
+
"backend": session_status.backend.value,
|
|
97
|
+
"pid": session_status.pid,
|
|
98
|
+
"created_at": session_status.created_at,
|
|
99
|
+
"window_count": session_status.window_count,
|
|
100
|
+
"image": session_status.image,
|
|
101
|
+
"exit_code": session_status.exit_code,
|
|
102
|
+
}
|
|
103
|
+
console.print(json_lib.dumps(data, indent=2))
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
if quiet:
|
|
107
|
+
console.print(f"{name}: {session_status.state.value}")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Rich table output
|
|
111
|
+
table = Table(title=f"Session: {name}")
|
|
112
|
+
table.add_column("Property", style="cyan")
|
|
113
|
+
table.add_column("Value", style="white")
|
|
114
|
+
|
|
115
|
+
table.add_row("State", session_status.state.value)
|
|
116
|
+
table.add_row("Backend", session_status.backend.value)
|
|
117
|
+
|
|
118
|
+
# Add optional fields as rows (reduces branching complexity)
|
|
119
|
+
optional_rows = [
|
|
120
|
+
("PID", str(session_status.pid) if session_status.pid else None),
|
|
121
|
+
("Created", _format_created_at(session_status.created_at)),
|
|
122
|
+
("Windows", str(session_status.window_count) if session_status.window_count > 0 else None),
|
|
123
|
+
("Image", session_status.image),
|
|
124
|
+
("Exit Code", str(session_status.exit_code) if session_status.exit_code is not None else None),
|
|
125
|
+
]
|
|
126
|
+
for label, value in optional_rows:
|
|
127
|
+
if value:
|
|
128
|
+
table.add_row(label, value)
|
|
129
|
+
|
|
130
|
+
console.print(table)
|
|
131
|
+
|
|
132
|
+
except SessionNotFoundError:
|
|
133
|
+
exit_error(f"Session '{name}' not found.")
|
|
134
|
+
except OpsError as e:
|
|
135
|
+
exit_error(str(e))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = ["status"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Stop a session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kstlib.cli.common import (
|
|
6
|
+
CommandResult,
|
|
7
|
+
CommandStatus,
|
|
8
|
+
emit_result,
|
|
9
|
+
exit_error,
|
|
10
|
+
)
|
|
11
|
+
from kstlib.ops.exceptions import OpsError, SessionNotFoundError
|
|
12
|
+
|
|
13
|
+
from .common import (
|
|
14
|
+
BACKEND_OPTION,
|
|
15
|
+
FORCE_OPTION,
|
|
16
|
+
QUIET_OPTION,
|
|
17
|
+
SESSION_ARGUMENT,
|
|
18
|
+
TIMEOUT_OPTION,
|
|
19
|
+
get_session_manager,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stop(
|
|
24
|
+
name: str = SESSION_ARGUMENT,
|
|
25
|
+
backend: str | None = BACKEND_OPTION,
|
|
26
|
+
quiet: bool = QUIET_OPTION,
|
|
27
|
+
force: bool = FORCE_OPTION,
|
|
28
|
+
timeout: int = TIMEOUT_OPTION,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Stop a running session.
|
|
31
|
+
|
|
32
|
+
Stops a tmux session or container. By default, attempts a graceful
|
|
33
|
+
shutdown first, then forces termination if the timeout is exceeded.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
kstlib ops stop dev
|
|
37
|
+
kstlib ops stop prod --force
|
|
38
|
+
kstlib ops stop bot --timeout 30
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
manager = get_session_manager(name, backend=backend)
|
|
42
|
+
|
|
43
|
+
if not manager.exists():
|
|
44
|
+
exit_error(f"Session '{name}' not found.")
|
|
45
|
+
|
|
46
|
+
manager.stop(graceful=not force, timeout=timeout)
|
|
47
|
+
|
|
48
|
+
result = CommandResult(
|
|
49
|
+
status=CommandStatus.OK,
|
|
50
|
+
message=f"Session '{name}' stopped.",
|
|
51
|
+
)
|
|
52
|
+
emit_result(result, quiet)
|
|
53
|
+
|
|
54
|
+
except SessionNotFoundError:
|
|
55
|
+
exit_error(f"Session '{name}' not found.")
|
|
56
|
+
except OpsError as e:
|
|
57
|
+
exit_error(str(e))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["stop"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""CLI commands for REST API client (rapi)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import typer
|
|
7
|
+
from typer.core import TyperGroup
|
|
8
|
+
|
|
9
|
+
from .call import call
|
|
10
|
+
from .list import list_endpoints
|
|
11
|
+
from .show import show_endpoint
|
|
12
|
+
|
|
13
|
+
# Known subcommands that should not be treated as endpoints
|
|
14
|
+
_SUBCOMMANDS = {"list", "call", "show", "--help", "-h", "help"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RapiGroup(TyperGroup):
|
|
18
|
+
"""Custom Typer Group that treats unknown commands as endpoint calls."""
|
|
19
|
+
|
|
20
|
+
def resolve_command(
|
|
21
|
+
self,
|
|
22
|
+
ctx: click.Context,
|
|
23
|
+
args: list[str],
|
|
24
|
+
) -> tuple[str | None, click.Command | None, list[str]]:
|
|
25
|
+
"""Override command resolution to treat unknown commands as endpoints."""
|
|
26
|
+
# Try normal resolution first
|
|
27
|
+
try:
|
|
28
|
+
return super().resolve_command(ctx, args)
|
|
29
|
+
except click.UsageError:
|
|
30
|
+
# If command not found and looks like an endpoint, redirect to call
|
|
31
|
+
if args and args[0] not in _SUBCOMMANDS and "." in args[0]:
|
|
32
|
+
# Treat as implicit call: prepend "call" to args
|
|
33
|
+
return super().resolve_command(ctx, ["call", *args])
|
|
34
|
+
raise
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
rapi_app = typer.Typer(
|
|
38
|
+
help="Config-driven REST API client.",
|
|
39
|
+
cls=RapiGroup,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Register explicit commands
|
|
43
|
+
rapi_app.command(name="list")(list_endpoints)
|
|
44
|
+
rapi_app.command(name="show")(show_endpoint)
|
|
45
|
+
# Keep "call" for explicit usage (shown in help)
|
|
46
|
+
rapi_app.command(name="call", hidden=False)(call)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register_cli(app: typer.Typer) -> None:
|
|
50
|
+
"""Register the rapi sub-commands on the root Typer app."""
|
|
51
|
+
app.add_typer(rapi_app, name="rapi")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"call",
|
|
56
|
+
"list_endpoints",
|
|
57
|
+
"rapi_app",
|
|
58
|
+
"register_cli",
|
|
59
|
+
"show_endpoint",
|
|
60
|
+
]
|