mlspace-plugin 0.2.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.
- mlspace_mcp/__init__.py +3 -0
- mlspace_mcp/__main__.py +125 -0
- mlspace_mcp/_status.py +18 -0
- mlspace_mcp/auth.py +109 -0
- mlspace_mcp/bodyspec.py +184 -0
- mlspace_mcp/client.py +170 -0
- mlspace_mcp/client_setup.py +235 -0
- mlspace_mcp/config.py +283 -0
- mlspace_mcp/errors.py +241 -0
- mlspace_mcp/formatting.py +502 -0
- mlspace_mcp/init_cli.py +356 -0
- mlspace_mcp/job_hooks.py +242 -0
- mlspace_mcp/jsonc.py +90 -0
- mlspace_mcp/overview.py +421 -0
- mlspace_mcp/registry.py +632 -0
- mlspace_mcp/resource_refs.py +188 -0
- mlspace_mcp/server.py +190 -0
- mlspace_mcp/setup_ui.py +103 -0
- mlspace_mcp/skills/mlspace-call-deployed-model-predict/SKILL.md +36 -0
- mlspace_mcp/skills/mlspace-deploy-inference-service/SKILL.md +42 -0
- mlspace_mcp/skills/mlspace-diagnose-data-transfer/SKILL.md +41 -0
- mlspace_mcp/skills/mlspace-diagnose-failed-training-job/SKILL.md +64 -0
- mlspace_mcp/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +40 -0
- mlspace_mcp/skills/mlspace-discover-launchable-resources/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-launch-training-job/SKILL.md +66 -0
- mlspace_mcp/skills/mlspace-list-running-jobs/SKILL.md +43 -0
- mlspace_mcp/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-stage-s3-to-nfs/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-transfer-compute-node/SKILL.md +63 -0
- mlspace_mcp/spec/body_schemas.json +7506 -0
- mlspace_mcp/spec/openapi.json +28347 -0
- mlspace_mcp/tls.py +46 -0
- mlspace_mcp/tools/__init__.py +1 -0
- mlspace_mcp/tools/allocations.py +92 -0
- mlspace_mcp/tools/async_inference.py +77 -0
- mlspace_mcp/tools/build_image.py +76 -0
- mlspace_mcp/tools/dalle.py +52 -0
- mlspace_mcp/tools/data_transfer.py +260 -0
- mlspace_mcp/tools/docker_registry.py +156 -0
- mlspace_mcp/tools/inference.py +88 -0
- mlspace_mcp/tools/jobs.py +155 -0
- mlspace_mcp/tools/notebooks.py +174 -0
- mlspace_mcp/tools/queues.py +202 -0
- mlspace_mcp/tools/resources.py +86 -0
- mlspace_mcp/tools/tensorboards.py +112 -0
- mlspace_mcp/tools/workspaces.py +92 -0
- mlspace_mcp/workspace_context.py +107 -0
- mlspace_plugin-0.2.1.dist-info/METADATA +427 -0
- mlspace_plugin-0.2.1.dist-info/RECORD +53 -0
- mlspace_plugin-0.2.1.dist-info/WHEEL +4 -0
- mlspace_plugin-0.2.1.dist-info/entry_points.txt +2 -0
- mlspace_plugin-0.2.1.dist-info/licenses/LICENSE +21 -0
mlspace_mcp/init_cli.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Terminal onboarding shared by humans and explicitly configured automation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .client_setup import (
|
|
19
|
+
CLIENTS,
|
|
20
|
+
atomic_write,
|
|
21
|
+
detect_clients,
|
|
22
|
+
install_clients,
|
|
23
|
+
prepare_clients,
|
|
24
|
+
server_command,
|
|
25
|
+
)
|
|
26
|
+
from .config import DEFAULT_BASE_URL, LITERAL_DOTENV_MARKER, read_dotenv_file, user_config_file
|
|
27
|
+
from .setup_ui import choose_workspaces, display, masked_prompt
|
|
28
|
+
from .tls import safe_error as _safe_error
|
|
29
|
+
from .tls import tls_context
|
|
30
|
+
|
|
31
|
+
HEADER = f'# MLSpace credentials, written by mlspace-plugin setup.\n{LITERAL_DOTENV_MARKER}\n'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ask(label: str, secret: bool = True, hidden: bool = True) -> str:
|
|
35
|
+
while True:
|
|
36
|
+
value = masked_prompt(f' {label}: ') if hidden else input(f' {label}: ')
|
|
37
|
+
if re.match(r'^\s*(?:export\s+)?MLSPACE_\w+\s*=', value):
|
|
38
|
+
print('Enter only the value, without MLSPACE_...= or export.', file=sys.stderr)
|
|
39
|
+
elif value.strip():
|
|
40
|
+
return value if secret else value.strip()
|
|
41
|
+
else:
|
|
42
|
+
print('Required. Paste the value; asterisks show that it was entered.', file=sys.stderr)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_base_url(target: Path, override: str | None) -> str:
|
|
46
|
+
saved = read_dotenv_file(target) if target.is_file() else {}
|
|
47
|
+
return ((override or '').strip() or os.environ.get('MLSPACE_BASE_URL', '').strip()
|
|
48
|
+
or str(saved.get('MLSPACE_BASE_URL') or DEFAULT_BASE_URL)).rstrip('/')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fetch_workspaces(key_id: str, key_secret: str, *, base_url: str,
|
|
52
|
+
ca_file: str = '') -> list[dict[str, str]]:
|
|
53
|
+
from .auth import TokenManager
|
|
54
|
+
from .client import MLSpaceClient
|
|
55
|
+
|
|
56
|
+
async def fetch() -> Any:
|
|
57
|
+
async with httpx.AsyncClient(timeout=30, verify=tls_context(ca_file)) as http:
|
|
58
|
+
tokens = TokenManager(http, base_url=base_url, client_id=key_id, client_secret=key_secret)
|
|
59
|
+
client = MLSpaceClient(http, tokens, base_url=base_url, api_key='', workspace_id='')
|
|
60
|
+
return await client.request('GET', '/public/v2/workspaces/v3/')
|
|
61
|
+
|
|
62
|
+
response = asyncio.run(fetch())
|
|
63
|
+
spaces = response.get('workspaces') if isinstance(response, dict) else None
|
|
64
|
+
if not isinstance(spaces, list) or not spaces:
|
|
65
|
+
raise ValueError('No workspaces visible to these access keys.')
|
|
66
|
+
catalogue = []
|
|
67
|
+
seen = set()
|
|
68
|
+
for row in spaces:
|
|
69
|
+
if not isinstance(row, dict) or not (key := row.get('id') or row.get('workspace_id')):
|
|
70
|
+
raise ValueError('Workspace API returned an entry without an ID.')
|
|
71
|
+
if str(key) in seen:
|
|
72
|
+
raise ValueError('Workspace API returned duplicate IDs.')
|
|
73
|
+
seen.add(str(key))
|
|
74
|
+
catalogue.append({'id': str(key), 'name': str(row.get('name') or key),
|
|
75
|
+
'project_name': str(row.get('project_name') or '')})
|
|
76
|
+
return sorted(catalogue, key=lambda r: (r['name'].casefold(), r['project_name'], r['id']))
|
|
77
|
+
def _write(path: Path, values: dict[str, str]) -> None:
|
|
78
|
+
"""Atomically replace ``path`` with a 0600 dotenv file."""
|
|
79
|
+
if path.is_symlink():
|
|
80
|
+
raise ValueError("Credentials target must not be a symlink.")
|
|
81
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
82
|
+
if path.parent == user_config_file().parent:
|
|
83
|
+
path.parent.chmod(0o700)
|
|
84
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
85
|
+
temporary = Path(temporary_name)
|
|
86
|
+
try:
|
|
87
|
+
os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
|
|
88
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
89
|
+
body = "".join(
|
|
90
|
+
f"MLSPACE_{name}={json.dumps(value, ensure_ascii=False)}\n"
|
|
91
|
+
for name, value in values.items()
|
|
92
|
+
)
|
|
93
|
+
fh.write(HEADER + body)
|
|
94
|
+
fh.flush()
|
|
95
|
+
os.fsync(fh.fileno())
|
|
96
|
+
os.replace(temporary, path)
|
|
97
|
+
except BaseException:
|
|
98
|
+
try:
|
|
99
|
+
os.close(fd)
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
temporary.unlink(missing_ok=True)
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _verify(values: dict[str, str], *, base_url: str) -> str | None:
|
|
107
|
+
"""Check selected workspace details and IDs. Returns a safe error, or None.
|
|
108
|
+
|
|
109
|
+
The caller preserves the previous file if any selected workspace fails.
|
|
110
|
+
"""
|
|
111
|
+
import asyncio
|
|
112
|
+
|
|
113
|
+
import httpx
|
|
114
|
+
from pydantic import SecretStr
|
|
115
|
+
|
|
116
|
+
from .auth import TokenManager
|
|
117
|
+
from .config import Settings
|
|
118
|
+
from .workspace_context import WorkspaceClients
|
|
119
|
+
|
|
120
|
+
async def run() -> str | None:
|
|
121
|
+
try:
|
|
122
|
+
# Explicit values keep old environment selectors out of this check.
|
|
123
|
+
settings = Settings( # type: ignore[call-arg]
|
|
124
|
+
_env_file=None,
|
|
125
|
+
base_url=base_url,
|
|
126
|
+
client_id=values["CLIENT_ID"],
|
|
127
|
+
client_secret=SecretStr(values["CLIENT_SECRET"]),
|
|
128
|
+
api_key=SecretStr(values.get("API_KEY", "")),
|
|
129
|
+
workspace_id=values.get("WORKSPACE_ID", ""),
|
|
130
|
+
workspaces=json.loads(values.get("WORKSPACES", "[]")),
|
|
131
|
+
namespace="",
|
|
132
|
+
)
|
|
133
|
+
async with httpx.AsyncClient(timeout=30, verify=tls_context(values.get("CA_FILE", ""))) as http:
|
|
134
|
+
tokens = TokenManager(
|
|
135
|
+
http, base_url=base_url, client_id=values["CLIENT_ID"],
|
|
136
|
+
client_secret=values["CLIENT_SECRET"],
|
|
137
|
+
)
|
|
138
|
+
clients = WorkspaceClients(settings, http, tokens)
|
|
139
|
+
verified = 0
|
|
140
|
+
errors = []
|
|
141
|
+
for selection in clients.selected:
|
|
142
|
+
try:
|
|
143
|
+
client = await clients.resolve(selection.id)
|
|
144
|
+
data = await client.request(
|
|
145
|
+
"GET", "/public/v2/workspaces/v3/{workspace_id}",
|
|
146
|
+
path_params={"workspace_id": selection.id},
|
|
147
|
+
)
|
|
148
|
+
if not isinstance(data, dict) or data.get("id") != selection.id:
|
|
149
|
+
errors.append(f"{selection.id}: workspace identity was not confirmed")
|
|
150
|
+
continue
|
|
151
|
+
verified += 1
|
|
152
|
+
except Exception as exc: # noqa: BLE001 — verify every selected workspace
|
|
153
|
+
errors.append(f"{selection.id}: {_safe_error(exc)}")
|
|
154
|
+
print(
|
|
155
|
+
f" checked: {verified}/{len(clients.selected)} selected workspace(s) verified "
|
|
156
|
+
"(workspace details and ID)",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
return "; ".join(errors) or None
|
|
160
|
+
except Exception as exc: # noqa: BLE001 — report failures without exposing transport details
|
|
161
|
+
return _safe_error(exc)
|
|
162
|
+
|
|
163
|
+
return asyncio.run(run())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _clients(requested: list[str], paths: list[str], non_interactive: bool) -> dict[str, str]:
|
|
168
|
+
detected = detect_clients()
|
|
169
|
+
for item in paths:
|
|
170
|
+
name, separator, path = item.partition('=')
|
|
171
|
+
executable = Path(path).expanduser().absolute()
|
|
172
|
+
if not separator or name not in CLIENTS or not executable.is_file() or not os.access(executable, os.X_OK):
|
|
173
|
+
raise ValueError('Use --client-path CLIENT=/absolute/path/to/executable.')
|
|
174
|
+
detected[name] = str(executable)
|
|
175
|
+
if requested:
|
|
176
|
+
if any(name not in detected for name in requested):
|
|
177
|
+
raise ValueError('Requested client not found. Install it or use --client-path CLIENT=/path.')
|
|
178
|
+
return {name: detected[name] for name in requested}
|
|
179
|
+
if len(detected) == 1:
|
|
180
|
+
print('Client detected: ' + next(iter(detected)), file=sys.stderr)
|
|
181
|
+
return detected
|
|
182
|
+
if not detected:
|
|
183
|
+
if non_interactive:
|
|
184
|
+
raise ValueError('No coding client found; use --client-path or --config-only.')
|
|
185
|
+
print('No Claude Code, Codex or OpenCode found. Enter CLIENT=/absolute/path, '
|
|
186
|
+
'or press Enter to stop and install a client.', file=sys.stderr)
|
|
187
|
+
custom = input('Client executable: ').strip()
|
|
188
|
+
if custom:
|
|
189
|
+
return _clients([], [custom], True)
|
|
190
|
+
raise ValueError('Install Claude Code, Codex or OpenCode, then run setup again. '
|
|
191
|
+
'For other MCP clients use --config-only.')
|
|
192
|
+
if non_interactive:
|
|
193
|
+
raise ValueError('Multiple clients found; select with --client (repeat for several).')
|
|
194
|
+
print('Choose clients: type to search, Space to select, Enter to continue.', file=sys.stderr)
|
|
195
|
+
selected = choose_workspaces([{'id': name, 'name': name, 'project_name': path}
|
|
196
|
+
for name, path in detected.items()])
|
|
197
|
+
return {row['id']: detected[row['id']] for row in selected}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def run_init(path: Path | None = None, *, force: bool = False, verify: bool = True,
|
|
201
|
+
base_url: str | None = None, config_only: bool = False,
|
|
202
|
+
non_interactive: bool = False, from_env: bool = False,
|
|
203
|
+
credentials_file: Path | None = None, workspace_ids: list[str] | None = None,
|
|
204
|
+
clients: list[str] | None = None, client_paths: list[str] | None = None,
|
|
205
|
+
ca_file: Path | None = None, replace_credentials: bool = False,
|
|
206
|
+
list_workspaces: bool = False, search: str = '', offset: int = 0, limit: int = 20) -> int:
|
|
207
|
+
try:
|
|
208
|
+
return _run_init(path, force=force, verify=verify, base_url=base_url,
|
|
209
|
+
config_only=config_only, non_interactive=non_interactive,
|
|
210
|
+
from_env=from_env, credentials_file=credentials_file,
|
|
211
|
+
workspace_ids=workspace_ids or [], clients=clients or [],
|
|
212
|
+
client_paths=client_paths or [], ca_file=ca_file,
|
|
213
|
+
replace_credentials=replace_credentials, list_workspaces=list_workspaces,
|
|
214
|
+
search=search, offset=offset, limit=limit)
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
print('\nSetup cancelled; previous credentials preserved unless saving already completed.', file=sys.stderr)
|
|
217
|
+
return 130
|
|
218
|
+
except EOFError:
|
|
219
|
+
print('\nNo interactive input. Run `mlspace-plugin setup` yourself in a terminal (SSH works). '
|
|
220
|
+
'Agents: use INSTALL_AGENT.md; never ask for keys in chat.', file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
except (ValueError, OSError, httpx.HTTPError) as exc:
|
|
223
|
+
# Only our own ValueErrors carry displayable text. Third-party errors may echo keys.
|
|
224
|
+
message = str(exc) if type(exc) is ValueError else _safe_error(exc)
|
|
225
|
+
print('Setup failed: ' + display(message), file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _run_init(path: Path | None, **options: Any) -> int:
|
|
230
|
+
target = (path or user_config_file()).expanduser().absolute()
|
|
231
|
+
listing = options['list_workspaces']
|
|
232
|
+
non_interactive = options['non_interactive']
|
|
233
|
+
if not non_interactive and not sys.stdin.isatty():
|
|
234
|
+
raise EOFError
|
|
235
|
+
if options['from_env'] and options['credentials_file']:
|
|
236
|
+
raise ValueError('Choose either --from-env or --credentials-file.')
|
|
237
|
+
if target.exists() and not options['force'] and not listing:
|
|
238
|
+
raise ValueError('Configuration already exists. Use --force to reconfigure; saved keys will be reused.')
|
|
239
|
+
if not options['verify'] and (not options['config_only'] or not options['workspace_ids']):
|
|
240
|
+
raise ValueError('--no-verify requires --config-only and explicit --workspace IDs; result is unverified.')
|
|
241
|
+
if options['offset'] < 0 or not 1 <= options['limit'] <= 100:
|
|
242
|
+
raise ValueError('Use offset >= 0 and limit between 1 and 100.')
|
|
243
|
+
saved = read_dotenv_file(target) if target.is_file() else {}
|
|
244
|
+
endpoint = _resolve_base_url(target, options['base_url'])
|
|
245
|
+
parsed = urlsplit(endpoint)
|
|
246
|
+
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
247
|
+
raise ValueError('API endpoint must be an HTTPS URL without credentials, query or fragment.')
|
|
248
|
+
if os.environ.get('SSL_CERT_DIR'):
|
|
249
|
+
raise ValueError('SSL_CERT_DIR cannot be carried into client startup. Unset it for setup and use '
|
|
250
|
+
'--ca-file /path/to/trusted-ca.pem or install the CA in the system trust store.')
|
|
251
|
+
selected_clients = {} if options['config_only'] or listing else _clients(
|
|
252
|
+
options['clients'], options['client_paths'], non_interactive)
|
|
253
|
+
command = server_command() if selected_clients else []
|
|
254
|
+
if selected_clients:
|
|
255
|
+
prepare_clients(selected_clients, target, command)
|
|
256
|
+
if not listing:
|
|
257
|
+
print('[1/4] Access keys. Paste values only; asterisks hide both fields. '
|
|
258
|
+
'Keys stay in your user config, not in chat.', file=sys.stderr)
|
|
259
|
+
if options['credentials_file']:
|
|
260
|
+
source = read_dotenv_file(options['credentials_file'].expanduser())
|
|
261
|
+
elif options['from_env']:
|
|
262
|
+
source = dict(os.environ)
|
|
263
|
+
elif not options['replace_credentials'] and str(saved.get('MLSPACE_BASE_URL') or DEFAULT_BASE_URL).rstrip('/') == endpoint:
|
|
264
|
+
source = saved
|
|
265
|
+
else:
|
|
266
|
+
source = {}
|
|
267
|
+
keys = {}
|
|
268
|
+
for name, label in [('CLIENT_ID', 'Cloud.ru Key ID'), ('CLIENT_SECRET', 'Cloud.ru Key Secret')]:
|
|
269
|
+
value = source.get('MLSPACE_' + name)
|
|
270
|
+
if not value or not value.strip():
|
|
271
|
+
if non_interactive or options['credentials_file'] or options['from_env']:
|
|
272
|
+
raise ValueError('Missing credentials for this endpoint. Run setup in your terminal, '
|
|
273
|
+
'or explicitly select --from-env / --credentials-file.')
|
|
274
|
+
value = _ask(label, secret=name == 'CLIENT_SECRET')
|
|
275
|
+
keys[name] = value
|
|
276
|
+
ca = str(options['ca_file'] or saved.get('MLSPACE_CA_FILE') or os.environ.get('SSL_CERT_FILE') or '')
|
|
277
|
+
ca = str(Path(ca).expanduser().absolute()) if ca else ''
|
|
278
|
+
if not listing:
|
|
279
|
+
print('[2/4] Checking TLS and loading workspaces…', file=sys.stderr)
|
|
280
|
+
if options['verify'] or listing:
|
|
281
|
+
while True:
|
|
282
|
+
try:
|
|
283
|
+
rows = _fetch_workspaces(keys['CLIENT_ID'], keys['CLIENT_SECRET'], base_url=endpoint, ca_file=ca)
|
|
284
|
+
break
|
|
285
|
+
except ValueError:
|
|
286
|
+
raise
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
error = _safe_error(exc)
|
|
289
|
+
if not non_interactive and 'TLS certificate' in error:
|
|
290
|
+
print(error, file=sys.stderr)
|
|
291
|
+
ca = input('Trusted CA PEM path from your administrator (Enter to cancel): ').strip()
|
|
292
|
+
if not ca:
|
|
293
|
+
raise ValueError('TLS setup stopped; no credentials saved.') from None
|
|
294
|
+
ca = str(Path(ca).expanduser().absolute())
|
|
295
|
+
continue
|
|
296
|
+
raise ValueError(error) from None
|
|
297
|
+
else:
|
|
298
|
+
rows = [{'id': key, 'name': key, 'project_name': ''} for key in dict.fromkeys(options['workspace_ids'])]
|
|
299
|
+
if listing:
|
|
300
|
+
terms = options['search'].casefold().split()
|
|
301
|
+
matches = [row for row in rows if all(term in ' '.join(row.values()).casefold() for term in terms)]
|
|
302
|
+
offset, limit = options['offset'], options['limit']
|
|
303
|
+
print(json.dumps({'total': len(matches), 'offset': offset, 'limit': limit,
|
|
304
|
+
'has_more': offset + limit < len(matches), 'workspaces': matches[offset:offset+limit]}, ensure_ascii=False))
|
|
305
|
+
return 0
|
|
306
|
+
requested = options['workspace_ids']
|
|
307
|
+
if requested:
|
|
308
|
+
if set(requested) - {row['id'] for row in rows}:
|
|
309
|
+
raise ValueError('Requested workspace IDs are not in the accessible workspace list.')
|
|
310
|
+
selected = [row for row in rows if row['id'] in requested]
|
|
311
|
+
elif non_interactive:
|
|
312
|
+
raise ValueError('Select workspace IDs with --workspace. Use --list-workspaces --search to find them.')
|
|
313
|
+
elif len(rows) == 1:
|
|
314
|
+
selected = rows
|
|
315
|
+
else:
|
|
316
|
+
print('Find your workspaces: type a name, project or ID. Space selects; Enter finishes.', file=sys.stderr)
|
|
317
|
+
old = json.loads(str(saved.get('MLSPACE_WORKSPACES') or '[]'))
|
|
318
|
+
selected = choose_workspaces(rows, [row['id'] for row in old if isinstance(row, dict) and 'id' in row])
|
|
319
|
+
values = {key.removeprefix('MLSPACE_'): value for key, value in saved.items()
|
|
320
|
+
if key.startswith('MLSPACE_') and value is not None
|
|
321
|
+
and key not in {'MLSPACE_API_KEY', 'MLSPACE_WORKSPACE_ID', 'MLSPACE_WORKSPACES', 'MLSPACE_NAMESPACE'}}
|
|
322
|
+
values.update(keys)
|
|
323
|
+
values.update(BASE_URL=endpoint, CA_FILE=ca,
|
|
324
|
+
WORKSPACES=json.dumps(selected, ensure_ascii=False, separators=(',', ':')))
|
|
325
|
+
if len(selected) == 1:
|
|
326
|
+
values['WORKSPACE_ID'] = selected[0]['id']
|
|
327
|
+
print(f'[3/4] Verifying {len(selected)} selected workspace(s)…', file=sys.stderr)
|
|
328
|
+
if options['verify']:
|
|
329
|
+
problem = _verify(values, base_url=endpoint)
|
|
330
|
+
if problem:
|
|
331
|
+
raise ValueError(problem)
|
|
332
|
+
if ca:
|
|
333
|
+
# Persist the validated public CA independently of /tmp and shell environment.
|
|
334
|
+
content = Path(ca).read_text(encoding='ascii')
|
|
335
|
+
destination = target.parent / ('ca-' + hashlib.sha256(content.encode()).hexdigest()[:16] + '.pem')
|
|
336
|
+
tls_context(ca)
|
|
337
|
+
atomic_write(destination, content)
|
|
338
|
+
values['CA_FILE'] = str(destination)
|
|
339
|
+
installed = []
|
|
340
|
+
if selected_clients:
|
|
341
|
+
print('[4/4] Connecting MCP and installing skills…', file=sys.stderr)
|
|
342
|
+
installed = install_clients(selected_clients, target, command)
|
|
343
|
+
_write(target, values)
|
|
344
|
+
print(f'Credentials saved: {target} (0600).', file=sys.stderr)
|
|
345
|
+
if not options['verify']:
|
|
346
|
+
print('UNVERIFIED configuration saved by explicit request. No client was connected.', file=sys.stderr)
|
|
347
|
+
return 0
|
|
348
|
+
if selected_clients:
|
|
349
|
+
for client in installed:
|
|
350
|
+
print(f' {client}: MCP registered and skills installed.', file=sys.stderr)
|
|
351
|
+
print('Done. Open a new client session (restart it if MCP is not visible). '
|
|
352
|
+
'The client starts the MCP server automatically.\n'
|
|
353
|
+
'Try: «Покажи выбранные воркспейсы и запущенные задачи, ничего не меняй».', file=sys.stderr)
|
|
354
|
+
else:
|
|
355
|
+
print('API/TLS verified. Config-only setup complete; client integration was not requested.', file=sys.stderr)
|
|
356
|
+
return 0
|
mlspace_mcp/job_hooks.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Job-specific preflight, deletion guard and response compatibility hooks.
|
|
2
|
+
|
|
3
|
+
Leaf module: imports no dispatcher or domain declarations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._status import JOB_STATUS_CANON
|
|
11
|
+
from .errors import MLSpaceError
|
|
12
|
+
|
|
13
|
+
# Earlier API observations motivated these compatibility guards. They are not
|
|
14
|
+
# current-tenant evidence. Binary commands are documented; non-binary entrypoints
|
|
15
|
+
# retain regional NFS path checks. The API cannot stat a file or verify an image command.
|
|
16
|
+
NFS_SCRIPT_PREFIX = "/home/jovyan"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _preflight_job_region(values: dict[str, Any]) -> None:
|
|
20
|
+
"""Refuse a job `run` that leaves `region` to the API default.
|
|
21
|
+
|
|
22
|
+
Runs UNCONDITIONALLY — the region trap does not depend on how `script` looks,
|
|
23
|
+
so this must not sit inside the path branch (a missing or empty script would
|
|
24
|
+
otherwise skip it and let the default region through).
|
|
25
|
+
|
|
26
|
+
A top-level ``region=`` is accepted as well and copied into the body: `run`
|
|
27
|
+
carries no top-level region parameter, so it would otherwise be dropped
|
|
28
|
+
silently and the caller refused for something they did supply.
|
|
29
|
+
"""
|
|
30
|
+
body = values.get("body")
|
|
31
|
+
in_body = body.get("region") if isinstance(body, dict) else None
|
|
32
|
+
if isinstance(in_body, str) and in_body.strip():
|
|
33
|
+
return
|
|
34
|
+
top = values.get("region")
|
|
35
|
+
if isinstance(top, str) and top.strip():
|
|
36
|
+
if isinstance(body, dict):
|
|
37
|
+
body["region"] = top
|
|
38
|
+
return
|
|
39
|
+
raise MLSpaceError(
|
|
40
|
+
"`region` must be explicit for a job submission. The API default (DGX2-MT) "
|
|
41
|
+
"can select a different /home/jovyan volume and fail only after allocating "
|
|
42
|
+
"a node.",
|
|
43
|
+
hint=(
|
|
44
|
+
"Set body.region. For an NFS entrypoint, verify it from a notebook or "
|
|
45
|
+
"transfer attached to that same region because the Public API cannot stat "
|
|
46
|
+
"the target region's NFS; for type=binary, verify that the command or "
|
|
47
|
+
"executable is available in the selected image."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _preflight_job_script(values: dict[str, Any]) -> None:
|
|
53
|
+
"""Apply only the script checks supported for the selected job type.
|
|
54
|
+
|
|
55
|
+
Empty strings are unsafe for every type. ``binary`` accepts a command or executable
|
|
56
|
+
path, so the wrapper must preserve a non-empty value without pretending it can
|
|
57
|
+
resolve the command in the selected image. Other types retain the established NFS
|
|
58
|
+
path-shape checks. Existence is never claimed: the Public API has no remote stat.
|
|
59
|
+
"""
|
|
60
|
+
body = values.get("body")
|
|
61
|
+
script = body.get("script") if isinstance(body, dict) else values.get("script")
|
|
62
|
+
job_type = body.get("type") if isinstance(body, dict) else values.get("type")
|
|
63
|
+
if script is None:
|
|
64
|
+
return # absent/None: the API's own 422 is exemplary, don't duplicate it
|
|
65
|
+
if not isinstance(script, str):
|
|
66
|
+
return # the spec-driven body lint reports the type error in the real handler
|
|
67
|
+
if not script.strip():
|
|
68
|
+
raise MLSpaceError(
|
|
69
|
+
f"`script` is empty ({script!r}). The API would accept this and return a "
|
|
70
|
+
"job_name, then allocate a node and fail within seconds with an empty "
|
|
71
|
+
"error_message.",
|
|
72
|
+
hint=(
|
|
73
|
+
"Provide a non-empty entrypoint. For type=binary this may be a command "
|
|
74
|
+
"or executable path; for other job types use the documented NFS script "
|
|
75
|
+
f"path under {NFS_SCRIPT_PREFIX}/…."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
if job_type == "binary":
|
|
79
|
+
return
|
|
80
|
+
if any(seg == ".." for seg in script.split("/")):
|
|
81
|
+
# a '..' segment escapes the NFS home the prefix check exists to enforce:
|
|
82
|
+
# "/home/jovyan/../tmp/x.sh" resolves to /tmp/x.sh
|
|
83
|
+
problem = f"`script` contains a '..' path segment ({script!r})"
|
|
84
|
+
elif script == NFS_SCRIPT_PREFIX or script.endswith("/"):
|
|
85
|
+
problem = f"`script` points at a directory, not a file ({script!r})"
|
|
86
|
+
elif not script.startswith("/"):
|
|
87
|
+
problem = f"`script` is a relative path ({script!r})"
|
|
88
|
+
elif not script.startswith(NFS_SCRIPT_PREFIX + "/"):
|
|
89
|
+
problem = f"`script` points outside the region's NFS ({script!r})"
|
|
90
|
+
else:
|
|
91
|
+
return
|
|
92
|
+
raise MLSpaceError(
|
|
93
|
+
f"{problem}. The API would accept this and return a job_name, then the job "
|
|
94
|
+
f"would allocate a node and fail within seconds with an empty error_message.",
|
|
95
|
+
hint=(
|
|
96
|
+
f"The script must already exist on the chosen region's NFS home, under "
|
|
97
|
+
f"{NFS_SCRIPT_PREFIX}/… — upload it there first, then pass the absolute "
|
|
98
|
+
f"path to the file (e.g. {NFS_SCRIPT_PREFIX}/myproject/train.sh). The same "
|
|
99
|
+
"path in a different notebook region is a different volume."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def preflight_job_run(values: dict[str, Any]) -> None:
|
|
105
|
+
"""Composed preflight for ``POST /jobs`` (one dict key, two independent concerns).
|
|
106
|
+
|
|
107
|
+
Region first — it applies to every submission; then the script shape.
|
|
108
|
+
"""
|
|
109
|
+
_preflight_job_region(values)
|
|
110
|
+
_preflight_job_script(values)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _preflight_jobs_list_region(values: dict[str, Any]) -> None:
|
|
114
|
+
"""Refuse a jobs `list` with no region and explain the silent count=0.
|
|
115
|
+
|
|
116
|
+
Live finding: ``GET /jobs`` without ``region`` returns 200 ``{"count":0,
|
|
117
|
+
"jobs":[]}`` for EVERY region, while a scoped ``GET /jobs?region=<r>`` returns the
|
|
118
|
+
real, non-zero count — so an empty result here proves the scope is missing, not
|
|
119
|
+
that the workspace has no jobs. (The exact per-region count is a fast-moving live
|
|
120
|
+
datum, so it is deliberately NOT quoted here.) A deliberate refusal (like the
|
|
121
|
+
/home/jovyan precondition) beats leaking a false emptiness to the model.
|
|
122
|
+
"""
|
|
123
|
+
if not values.get("region"):
|
|
124
|
+
raise MLSpaceError(
|
|
125
|
+
'GET /jobs without `region` returns 200 {"count":0,"jobs":[]} for every '
|
|
126
|
+
"region; with a region it returns the real (non-zero) count — an empty "
|
|
127
|
+
"result here does NOT prove there are no jobs, only that the region scope "
|
|
128
|
+
"is missing.",
|
|
129
|
+
hint="Pass region=<e.g. DGX2-MT|A100-MT|SR003|SR005|SR006|SR008> (examples, "
|
|
130
|
+
"not a closed set). For existing jobs, use mlspace_jobs_overview, which "
|
|
131
|
+
"combines regions from configs and workspace allocations; configs alone "
|
|
132
|
+
"can omit allocated regions. Jobs live per region.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _preflight_jobs_list_status(values: dict[str, Any]) -> None:
|
|
137
|
+
"""Normalise a jobs `list` status filter to the enum casing the API accepts.
|
|
138
|
+
|
|
139
|
+
The list endpoint filters on the Capitalized ``JobStatus-Input`` enum, but a
|
|
140
|
+
model naturally passes get's lowercase status (e.g. 'failed'). This is a pure
|
|
141
|
+
translation to the value the API itself requires (precedent: the silent limit
|
|
142
|
+
clamp in the handler), NOT invention: unknown values are left untouched so the
|
|
143
|
+
API returns its own exemplary 422.
|
|
144
|
+
"""
|
|
145
|
+
raw = values.get("status")
|
|
146
|
+
if raw is None:
|
|
147
|
+
return
|
|
148
|
+
if isinstance(raw, (list, tuple)):
|
|
149
|
+
values["status"] = [JOB_STATUS_CANON.get(str(s).casefold(), s) for s in raw]
|
|
150
|
+
else:
|
|
151
|
+
values["status"] = JOB_STATUS_CANON.get(str(raw).casefold(), raw)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def preflight_jobs_list(values: dict[str, Any]) -> None:
|
|
155
|
+
"""Composed preflight for ``GET /jobs`` (one dict key, two concerns).
|
|
156
|
+
|
|
157
|
+
Region enforcement first (may refuse), then status-casing normalisation — the
|
|
158
|
+
two live as separate, independently-testable helpers but share a single
|
|
159
|
+
PREFLIGHT registration to avoid a key collision.
|
|
160
|
+
"""
|
|
161
|
+
_preflight_jobs_list_region(values)
|
|
162
|
+
_preflight_jobs_list_status(values)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _precheck_unconfirmed(job_name: Any, reason: str) -> MLSpaceError:
|
|
166
|
+
"""The honest fail-CLOSED refusal shared by every 'could not confirm existence'
|
|
167
|
+
branch of the delete guard."""
|
|
168
|
+
return MLSpaceError(
|
|
169
|
+
f"Could not confirm that job {job_name} exists before deleting it ({reason}). "
|
|
170
|
+
"The delete was NOT sent. MLSpace replies status=\"deleted\" with a fresh "
|
|
171
|
+
"deleted_at even for a name that does not exist, so issuing the DELETE without a "
|
|
172
|
+
"confirmed precheck could report a FALSE 'deleted'.",
|
|
173
|
+
hint="Retry, or confirm the exact job_name via `list` (with region) first, then "
|
|
174
|
+
"re-run the delete.",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def guard_job_delete(client: Any, values: dict[str, Any]) -> None:
|
|
179
|
+
"""Refuse a jobs delete unless a status precheck CONFIRMS the job exists.
|
|
180
|
+
|
|
181
|
+
Live finding: the delete endpoint replies 200 ``status="deleted"`` with a fresh
|
|
182
|
+
``deleted_at`` even for a name that never existed — a FALSE confirmation. One
|
|
183
|
+
cheap status GET before the destructive call is the gate. This guard is
|
|
184
|
+
FAIL-CLOSED (a safety validator, not a cosmetic transform): the DELETE proceeds
|
|
185
|
+
ONLY when the precheck returns a concrete live status. A positive
|
|
186
|
+
'job not found', a precheck error (timeout/403/500), or an unexpected shape all
|
|
187
|
+
refuse — because none of them proves the job is really there to delete.
|
|
188
|
+
|
|
189
|
+
Edge case (05#11): a job created <~2s ago also answers 'job not found' and is
|
|
190
|
+
blocked here — fail-safe (better to refuse than to falsely confirm), covered by
|
|
191
|
+
the retry-after-propagation hint.
|
|
192
|
+
"""
|
|
193
|
+
job_name = values.get("job_name")
|
|
194
|
+
try:
|
|
195
|
+
body = await client.request(
|
|
196
|
+
"GET", "/public/v2/jobs/{job_name}", path_params={"job_name": str(job_name)}
|
|
197
|
+
)
|
|
198
|
+
except MLSpaceError as exc:
|
|
199
|
+
# precheck could not reach a verdict (4xx/5xx/timeout/odd body already mapped
|
|
200
|
+
# to MLSpaceError) → fail-closed rather than let a blind DELETE claim success.
|
|
201
|
+
raise _precheck_unconfirmed(job_name, f"precheck failed: {exc.message}") from exc
|
|
202
|
+
except Exception as exc: # defensive: any non-mapped failure is still unconfirmed
|
|
203
|
+
raise _precheck_unconfirmed(job_name, f"precheck error: {type(exc).__name__}") from exc
|
|
204
|
+
|
|
205
|
+
raw_status = body.get("status") if isinstance(body, dict) else None
|
|
206
|
+
if not isinstance(raw_status, str):
|
|
207
|
+
# null / object / missing: str() would turn these into a truthy "none"/"{...}"
|
|
208
|
+
raise _precheck_unconfirmed(job_name, "precheck returned an unexpected shape")
|
|
209
|
+
status = raw_status.strip().casefold()
|
|
210
|
+
if status == "job not found":
|
|
211
|
+
raise MLSpaceError(
|
|
212
|
+
f"Job {job_name} does not exist (its status endpoint returns 'job not found') "
|
|
213
|
+
"— nothing to delete. The delete endpoint would still reply status=\"deleted\" "
|
|
214
|
+
"with a fresh deleted_at for this non-existent job; that is a FALSE confirmation.",
|
|
215
|
+
hint="Verify the exact job_name via `list` (with region); if you created it "
|
|
216
|
+
"<~2s ago, wait for it to propagate and retry.",
|
|
217
|
+
)
|
|
218
|
+
if not status:
|
|
219
|
+
raise _precheck_unconfirmed(job_name, "precheck returned no status")
|
|
220
|
+
# else: a concrete live status (pending/running/failed/…) → existence confirmed.
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def postprocess_job_delete(data: Any, values: dict[str, Any]) -> Any:
|
|
224
|
+
"""Annotate a jobs delete so the caller does not read 'deleted' as a purge.
|
|
225
|
+
|
|
226
|
+
Live finding: after a 200 ``status="deleted"`` the job KEEPS appearing in
|
|
227
|
+
`list`/`get` with a terminal status, and each repeated delete returns a fresh
|
|
228
|
+
``deleted_at``. MLSpace delete is therefore LOGICAL (mark-deleted/stopped), not a
|
|
229
|
+
history purge. We add a leading-underscore note and leave the original fields
|
|
230
|
+
untouched. Fail-open on any other shape."""
|
|
231
|
+
if isinstance(data, dict) and str(data.get("status", "")).strip().casefold() == "deleted":
|
|
232
|
+
return {
|
|
233
|
+
**data,
|
|
234
|
+
"_wrapper_note": (
|
|
235
|
+
"MLSpace delete is LOGICAL: status='deleted' with a fresh deleted_at means "
|
|
236
|
+
"the job was marked deleted/stopped, NOT purged. It can still appear in "
|
|
237
|
+
"`list`/`get` with a terminal status (completed/failed/stopped) afterward, "
|
|
238
|
+
"and a repeated delete returns a fresh deleted_at again — do NOT treat this "
|
|
239
|
+
"as proof the job is gone from history."
|
|
240
|
+
),
|
|
241
|
+
}
|
|
242
|
+
return data
|