modal-cursor 0.1.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.
- modal_cursor/__init__.py +30 -0
- modal_cursor/__main__.py +4 -0
- modal_cursor/cli.py +621 -0
- modal_cursor/control_plane.py +104 -0
- modal_cursor/controller.py +226 -0
- modal_cursor/otel_proxy.py +113 -0
- modal_cursor/pool.py +170 -0
- modal_cursor/pools.py +244 -0
- modal_cursor/py.typed +1 -0
- modal_cursor/registry.py +444 -0
- modal_cursor/spawn.py +182 -0
- modal_cursor/telemetry.py +226 -0
- modal_cursor/templates/pool.py.tmpl +17 -0
- modal_cursor-0.1.0.dist-info/METADATA +212 -0
- modal_cursor-0.1.0.dist-info/RECORD +17 -0
- modal_cursor-0.1.0.dist-info/WHEEL +4 -0
- modal_cursor-0.1.0.dist-info/entry_points.txt +2 -0
modal_cursor/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Modal-backed controller for Cursor bring-your-own-machine worker pools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from modal_cursor.pool import Pool
|
|
10
|
+
from modal_cursor.pools import ConfigError
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("modal-cursor")
|
|
14
|
+
except PackageNotFoundError:
|
|
15
|
+
__version__ = "0+unknown"
|
|
16
|
+
|
|
17
|
+
__all__ = ["ConfigError", "Pool", "__version__"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str) -> Any:
|
|
21
|
+
"""Load the Modal-facing API only when callers ask for it."""
|
|
22
|
+
if name == "Pool":
|
|
23
|
+
from modal_cursor.pool import Pool
|
|
24
|
+
|
|
25
|
+
return Pool
|
|
26
|
+
if name == "ConfigError":
|
|
27
|
+
from modal_cursor.pools import ConfigError
|
|
28
|
+
|
|
29
|
+
return ConfigError
|
|
30
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
modal_cursor/__main__.py
ADDED
modal_cursor/cli.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Command-line lifecycle management for generated Modal pool applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from string import Template
|
|
13
|
+
from typing import Annotated, Literal, Protocol, cast
|
|
14
|
+
|
|
15
|
+
import cyclopts
|
|
16
|
+
import httpx
|
|
17
|
+
import modal
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.prompt import Confirm, Prompt
|
|
21
|
+
|
|
22
|
+
from modal_cursor.pool import Pool
|
|
23
|
+
from modal_cursor.pools import ConfigError
|
|
24
|
+
from modal_cursor.registry import (
|
|
25
|
+
DEFAULT_API_ENDPOINT,
|
|
26
|
+
PoolScope,
|
|
27
|
+
RegisteredPool,
|
|
28
|
+
RegistrySchemaError,
|
|
29
|
+
cursor_client,
|
|
30
|
+
deregister_pool,
|
|
31
|
+
list_pools,
|
|
32
|
+
)
|
|
33
|
+
from modal_cursor.telemetry import instrument, record_exception, set_attribute, span
|
|
34
|
+
|
|
35
|
+
app = cyclopts.App(name="modal-cursor")
|
|
36
|
+
TEMPLATE = files("modal_cursor").joinpath("templates", "pool.py.tmpl")
|
|
37
|
+
CURSOR_DOCS_URL = "https://cursor.com/docs/account/enterprise/service-accounts"
|
|
38
|
+
CONTROL_PLANE_APP_NAME = "modal-cursor-control-plane"
|
|
39
|
+
CONTROL_PLANE_FILE = Path(__file__).with_name("control_plane.py")
|
|
40
|
+
POOL_FILES_ENV = "MODAL_CURSOR_POOL_FILES"
|
|
41
|
+
|
|
42
|
+
PoolNameArg = Annotated[
|
|
43
|
+
str, cyclopts.Parameter(help="Lowercase pool slug, for example gpu-training.")
|
|
44
|
+
]
|
|
45
|
+
RepoUrlOption = Annotated[
|
|
46
|
+
str, cyclopts.Parameter(help="HTTPS GitHub repository URL; omit for any-repo.")
|
|
47
|
+
]
|
|
48
|
+
ApiEndpointOption = Annotated[str, cyclopts.Parameter(help="Base URL for the Cursor API.")]
|
|
49
|
+
ScopeOption = Annotated[Literal["team", "user"], cyclopts.Parameter(help="Cursor pool scope.")]
|
|
50
|
+
SecretNameOption = Annotated[
|
|
51
|
+
str, cyclopts.Parameter(help="Modal Secret containing CURSOR_API_KEY.")
|
|
52
|
+
]
|
|
53
|
+
PoolFileArg = Annotated[
|
|
54
|
+
Path, cyclopts.Parameter(help="Generated pool file; omit to use --pools-dir.")
|
|
55
|
+
]
|
|
56
|
+
PoolsDirOption = Annotated[
|
|
57
|
+
Path, cyclopts.Parameter(help="Directory containing generated pool files.")
|
|
58
|
+
]
|
|
59
|
+
YesOption = Annotated[bool, cyclopts.Parameter(name=("--yes", "-y"), negative=False)]
|
|
60
|
+
|
|
61
|
+
_console = Console(highlight=False, markup=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ModalFunction(Protocol):
|
|
65
|
+
def spawn(self) -> object: ...
|
|
66
|
+
|
|
67
|
+
def get_current_stats(self) -> _ModalStats: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _ModalStats(Protocol):
|
|
71
|
+
num_total_runners: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _NamedSecret(Protocol):
|
|
75
|
+
name: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ok(text: str) -> None:
|
|
79
|
+
_console.print(f"✓ {text}", style="green")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _error(text: str) -> None:
|
|
83
|
+
_console.print(f"✖ {text}", style="red")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _warn(text: str) -> None:
|
|
87
|
+
_console.print(f"! {text}", style="yellow")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _interactive() -> bool:
|
|
91
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _ask(question: str, *, password: bool = False) -> str:
|
|
95
|
+
return Prompt.ask(
|
|
96
|
+
question, console=_console, password=password, default="", show_default=False
|
|
97
|
+
).strip()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _confirm(question: str, *, default: bool = False) -> bool:
|
|
101
|
+
return bool(Confirm.ask(question, console=_console, default=default))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _modal(
|
|
105
|
+
*args: str,
|
|
106
|
+
capture_output: bool = True,
|
|
107
|
+
env: Mapping[str, str] | None = None,
|
|
108
|
+
) -> subprocess.CompletedProcess[str]:
|
|
109
|
+
command = [sys.executable, "-m", "modal", *args]
|
|
110
|
+
if env is None:
|
|
111
|
+
return subprocess.run(
|
|
112
|
+
command, capture_output=capture_output, text=True, timeout=30, check=False
|
|
113
|
+
)
|
|
114
|
+
return subprocess.run(
|
|
115
|
+
command,
|
|
116
|
+
capture_output=capture_output,
|
|
117
|
+
text=True,
|
|
118
|
+
timeout=30,
|
|
119
|
+
check=False,
|
|
120
|
+
env=env,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _modal_is_configured() -> bool:
|
|
125
|
+
try:
|
|
126
|
+
return _modal("token", "info").returncode == 0
|
|
127
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _secret_names() -> set[str]:
|
|
132
|
+
"""Return configured Modal secret names behind one mockable SDK boundary."""
|
|
133
|
+
secrets = cast(Iterable[_NamedSecret], modal.Secret.objects.list())
|
|
134
|
+
return {secret.name for secret in secrets if secret.name}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _modal_deploy(pool_files: Path | Iterable[Path]) -> int:
|
|
138
|
+
files = (pool_files,) if isinstance(pool_files, Path) else tuple(pool_files)
|
|
139
|
+
env = os.environ.copy()
|
|
140
|
+
env[POOL_FILES_ENV] = os.pathsep.join(str(file.resolve()) for file in files)
|
|
141
|
+
return _modal(
|
|
142
|
+
"deploy", "--strategy", "rolling", str(CONTROL_PLANE_FILE), capture_output=False, env=env
|
|
143
|
+
).returncode
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _pool_files(pool_file: Path | None, pools_dir: Path) -> list[Path]:
|
|
147
|
+
candidates = [pool_file] if pool_file is not None else sorted(pools_dir.glob("*.py"))
|
|
148
|
+
if not candidates:
|
|
149
|
+
raise SystemExit(f"No pool files found in {pools_dir}")
|
|
150
|
+
return candidates
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pool_from_file(file: Path, *, scope: PoolScope = "team") -> Pool:
|
|
154
|
+
try:
|
|
155
|
+
return Pool(name=file.stem, scope=scope)
|
|
156
|
+
except (ConfigError, ValidationError) as error:
|
|
157
|
+
raise SystemExit(
|
|
158
|
+
f"{file} is not a valid pool file name (expected a slug like gpu-training.py)"
|
|
159
|
+
) from error
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _required_secrets(pool_file: Path) -> set[str]:
|
|
163
|
+
"""Read the generated literal secret declarations without executing pool code."""
|
|
164
|
+
try:
|
|
165
|
+
tree = ast.parse(pool_file.read_text(encoding="utf-8"), filename=str(pool_file))
|
|
166
|
+
except (OSError, SyntaxError) as error:
|
|
167
|
+
raise ConfigError(f"cannot inspect {pool_file}: {error}") from error
|
|
168
|
+
values: dict[str, object] = {}
|
|
169
|
+
for node in tree.body:
|
|
170
|
+
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
|
171
|
+
continue
|
|
172
|
+
target = node.targets[0]
|
|
173
|
+
if not isinstance(target, ast.Name) or target.id not in {
|
|
174
|
+
"CURSOR_SECRET_NAME",
|
|
175
|
+
"WORKER_SECRET_NAMES",
|
|
176
|
+
}:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
values[target.id] = ast.literal_eval(node.value)
|
|
180
|
+
except (TypeError, ValueError, SyntaxError) as error:
|
|
181
|
+
raise ConfigError(f"{pool_file}: {target.id} must be a literal") from error
|
|
182
|
+
cursor_secret = values.get("CURSOR_SECRET_NAME")
|
|
183
|
+
worker_secrets = values.get("WORKER_SECRET_NAMES", ())
|
|
184
|
+
if not isinstance(cursor_secret, str) or not cursor_secret:
|
|
185
|
+
raise ConfigError(f"{pool_file}: CURSOR_SECRET_NAME is missing")
|
|
186
|
+
if not isinstance(worker_secrets, (tuple, list)):
|
|
187
|
+
raise ConfigError(f"{pool_file}: WORKER_SECRET_NAMES must be a sequence of names")
|
|
188
|
+
raw_names = tuple(cast(Iterable[object], worker_secrets))
|
|
189
|
+
names = tuple(name for name in raw_names if isinstance(name, str))
|
|
190
|
+
if len(names) != len(raw_names) or not all(names):
|
|
191
|
+
raise ConfigError(f"{pool_file}: WORKER_SECRET_NAMES must be a sequence of names")
|
|
192
|
+
return {cursor_secret, *names}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _deploy_and_start(files: list[Path]) -> bool:
|
|
196
|
+
pools = [_pool_from_file(file) for file in files]
|
|
197
|
+
with span(
|
|
198
|
+
"modal_cursor.cli.deploy_control_plane",
|
|
199
|
+
**{
|
|
200
|
+
"modal_cursor.pool.count": len(pools),
|
|
201
|
+
"modal_cursor.pool.names": ",".join(pool.name for pool in pools),
|
|
202
|
+
},
|
|
203
|
+
) as current:
|
|
204
|
+
_console.print("Running modal deploy for " + ", ".join(str(file) for file in files) + "...")
|
|
205
|
+
if _modal_deploy(files) != 0:
|
|
206
|
+
set_attribute(current, "modal_cursor.outcome", "failure")
|
|
207
|
+
_error("Control-plane deployment failed")
|
|
208
|
+
return False
|
|
209
|
+
try:
|
|
210
|
+
from_name = cast(Callable[[str, str], _ModalFunction], modal.Function.from_name)
|
|
211
|
+
from_name(CONTROL_PLANE_APP_NAME, "controller").spawn()
|
|
212
|
+
except modal.exception.Error as error:
|
|
213
|
+
record_exception(current, error)
|
|
214
|
+
set_attribute(current, "modal_cursor.outcome", "failure")
|
|
215
|
+
_error(f"Deployed control plane, but the controller failed to start: {error}")
|
|
216
|
+
return False
|
|
217
|
+
_ok(
|
|
218
|
+
"Deployed the all-pools control plane; controller starting for "
|
|
219
|
+
+ ", ".join(pool.name for pool in pools)
|
|
220
|
+
)
|
|
221
|
+
set_attribute(current, "modal_cursor.outcome", "success")
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _stop_control_plane_app() -> bool:
|
|
226
|
+
"""Stop the one app that owns registration and dispatch for every pool."""
|
|
227
|
+
with span(
|
|
228
|
+
"modal_cursor.cli.stop_control_plane",
|
|
229
|
+
**{"modal_cursor.app.name": CONTROL_PLANE_APP_NAME},
|
|
230
|
+
) as current:
|
|
231
|
+
try:
|
|
232
|
+
modal.App.lookup(CONTROL_PLANE_APP_NAME, create_if_missing=False)
|
|
233
|
+
except modal.exception.NotFoundError:
|
|
234
|
+
set_attribute(current, "modal_cursor.outcome", "already_absent")
|
|
235
|
+
_ok(f"Modal app {CONTROL_PLANE_APP_NAME} is not deployed")
|
|
236
|
+
return True
|
|
237
|
+
except modal.exception.Error as error:
|
|
238
|
+
record_exception(current, error)
|
|
239
|
+
set_attribute(current, "modal_cursor.outcome", "failure")
|
|
240
|
+
_error(f"Could not inspect Modal app {CONTROL_PLANE_APP_NAME}: {error}")
|
|
241
|
+
return False
|
|
242
|
+
result = _modal("app", "stop", "--yes", CONTROL_PLANE_APP_NAME)
|
|
243
|
+
if result.returncode != 0:
|
|
244
|
+
set_attribute(current, "modal_cursor.outcome", "failure")
|
|
245
|
+
detail = (result.stderr or result.stdout).strip()
|
|
246
|
+
_error(f"Modal app {CONTROL_PLANE_APP_NAME} stop failed: {detail or result.returncode}")
|
|
247
|
+
return False
|
|
248
|
+
set_attribute(current, "modal_cursor.outcome", "success")
|
|
249
|
+
_ok(f"Stopped Modal app {CONTROL_PLANE_APP_NAME}")
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _deregister_matches(
|
|
254
|
+
client: httpx.Client,
|
|
255
|
+
pool: Pool,
|
|
256
|
+
scope: PoolScope,
|
|
257
|
+
registry: list[RegisteredPool],
|
|
258
|
+
) -> bool:
|
|
259
|
+
matches = [item for item in registry if item.name == pool.name and item.scope == scope]
|
|
260
|
+
if not matches:
|
|
261
|
+
_ok(f"Cursor pool {pool.name} is not registered")
|
|
262
|
+
return True
|
|
263
|
+
succeeded = True
|
|
264
|
+
for registered in matches:
|
|
265
|
+
try:
|
|
266
|
+
deregister_pool(client, registered)
|
|
267
|
+
except httpx.HTTPError as error:
|
|
268
|
+
_error(f"Cursor pool {pool.name} deregistration failed: {error}")
|
|
269
|
+
succeeded = False
|
|
270
|
+
continue
|
|
271
|
+
repo = (
|
|
272
|
+
f" for {registered.repository.owner}/{registered.repository.name}"
|
|
273
|
+
if registered.repository
|
|
274
|
+
else ""
|
|
275
|
+
)
|
|
276
|
+
_ok(f"Deregistered Cursor pool {pool.name}{repo}")
|
|
277
|
+
return succeeded
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _ensure_modal_secret(
|
|
281
|
+
*,
|
|
282
|
+
name: str,
|
|
283
|
+
key: str,
|
|
284
|
+
prompt: str,
|
|
285
|
+
existing: set[str],
|
|
286
|
+
interactive: bool,
|
|
287
|
+
value: str | None = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Create one explicitly requested secret, or print the exact prerequisite."""
|
|
290
|
+
if name in existing:
|
|
291
|
+
_ok(f"Modal secret {name} exists")
|
|
292
|
+
return
|
|
293
|
+
secret_value = value
|
|
294
|
+
if interactive and not secret_value:
|
|
295
|
+
secret_value = _ask(prompt, password=True)
|
|
296
|
+
if interactive and secret_value and _confirm(f"Save it as Modal secret {name}?", default=True):
|
|
297
|
+
try:
|
|
298
|
+
modal.Secret.objects.create(name, {key: secret_value})
|
|
299
|
+
except modal.exception.Error as error:
|
|
300
|
+
_error(f"Could not create Modal secret {name}: {error}")
|
|
301
|
+
else:
|
|
302
|
+
existing.add(name)
|
|
303
|
+
_ok(f"Created Modal secret {name}")
|
|
304
|
+
if name not in existing:
|
|
305
|
+
_warn(f"Create Modal secret {name} with a {key} value before deploying")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _check_local_pool(pool_file: Path, pool: Pool, secret_names: set[str]) -> int:
|
|
309
|
+
failures = 0
|
|
310
|
+
missing = sorted(_required_secrets(pool_file) - secret_names)
|
|
311
|
+
if missing:
|
|
312
|
+
_error(f"{pool_file} is missing Modal secret(s): {', '.join(missing)}")
|
|
313
|
+
failures += 1
|
|
314
|
+
else:
|
|
315
|
+
_ok(f"{pool_file} has all required Modal secrets")
|
|
316
|
+
return failures
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _check_control_plane() -> int:
|
|
320
|
+
try:
|
|
321
|
+
modal.App.lookup(CONTROL_PLANE_APP_NAME, create_if_missing=False)
|
|
322
|
+
from_name = cast(Callable[[str, str], _ModalFunction], modal.Function.from_name)
|
|
323
|
+
stats = from_name(CONTROL_PLANE_APP_NAME, "controller").get_current_stats()
|
|
324
|
+
except modal.exception.NotFoundError:
|
|
325
|
+
_error(f"No deployed {CONTROL_PLANE_APP_NAME} controller")
|
|
326
|
+
return 1
|
|
327
|
+
except modal.exception.Error as error:
|
|
328
|
+
_error(f"Could not inspect {CONTROL_PLANE_APP_NAME}: {error}")
|
|
329
|
+
return 1
|
|
330
|
+
if stats.num_total_runners < 1:
|
|
331
|
+
_error(f"{CONTROL_PLANE_APP_NAME} is deployed, but its controller has no running container")
|
|
332
|
+
return 1
|
|
333
|
+
_ok(f"{CONTROL_PLANE_APP_NAME} controller is running")
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _check_registry(
|
|
338
|
+
local_pools: list[tuple[Path, Pool]],
|
|
339
|
+
registry: list[RegisteredPool],
|
|
340
|
+
scope: PoolScope,
|
|
341
|
+
) -> int:
|
|
342
|
+
failures = 0
|
|
343
|
+
local_identities = {(pool.name, pool.scope, pool.repo_url) for _, pool in local_pools}
|
|
344
|
+
by_name: dict[str, list[RegisteredPool]] = {}
|
|
345
|
+
for item in registry:
|
|
346
|
+
if item.scope == scope:
|
|
347
|
+
by_name.setdefault(item.name, []).append(item)
|
|
348
|
+
for item in registry:
|
|
349
|
+
if item.scope != scope:
|
|
350
|
+
continue
|
|
351
|
+
identity = (item.name, item.scope, item.repository.url if item.repository else None)
|
|
352
|
+
if identity not in local_identities:
|
|
353
|
+
_error(f"Cursor pool {item.name} is registered but has no matching local pool file")
|
|
354
|
+
failures += 1
|
|
355
|
+
for pool_file, pool in local_pools:
|
|
356
|
+
matches = [
|
|
357
|
+
item
|
|
358
|
+
for item in by_name.get(pool.name, [])
|
|
359
|
+
if (item.repository.url if item.repository else None) == pool.repo_url
|
|
360
|
+
]
|
|
361
|
+
if not matches:
|
|
362
|
+
_error(f"{pool_file} is not registered with matching repository metadata")
|
|
363
|
+
failures += 1
|
|
364
|
+
continue
|
|
365
|
+
if len(matches) > 1:
|
|
366
|
+
_error(f"Cursor pool {pool.name} has duplicate matching registrations")
|
|
367
|
+
failures += 1
|
|
368
|
+
continue
|
|
369
|
+
registered = matches[0]
|
|
370
|
+
if registered.worker_ready_timeout_s != pool.worker_ready_timeout_s:
|
|
371
|
+
_error(
|
|
372
|
+
f"Cursor pool {pool.name} has workerReadyTimeoutSeconds="
|
|
373
|
+
f"{registered.worker_ready_timeout_s}; expected {pool.worker_ready_timeout_s}"
|
|
374
|
+
)
|
|
375
|
+
failures += 1
|
|
376
|
+
continue
|
|
377
|
+
connected = registered.connected_workers
|
|
378
|
+
in_use = registered.in_use_workers
|
|
379
|
+
_ok(f"Cursor pool {pool.name}: {connected} connected, {in_use} in use")
|
|
380
|
+
return failures
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.command
|
|
384
|
+
@instrument("modal_cursor.cli.deploy")
|
|
385
|
+
def deploy(
|
|
386
|
+
pool_file: PoolFileArg | None = None,
|
|
387
|
+
*,
|
|
388
|
+
pools_dir: PoolsDirOption = Path("pools"),
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Deploy one all-pools control plane and start its singleton controller."""
|
|
391
|
+
pool_files = _pool_files(pool_file, pools_dir)
|
|
392
|
+
try:
|
|
393
|
+
for file in pool_files:
|
|
394
|
+
_pool_from_file(file)
|
|
395
|
+
except SystemExit as error:
|
|
396
|
+
raise SystemExit(str(error)) from error
|
|
397
|
+
if not _deploy_and_start(pool_files):
|
|
398
|
+
raise SystemExit("Control-plane deployment failed")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.command
|
|
402
|
+
@instrument("modal_cursor.cli.destroy")
|
|
403
|
+
def destroy(
|
|
404
|
+
pool_file: PoolFileArg | None = None,
|
|
405
|
+
*,
|
|
406
|
+
pools_dir: PoolsDirOption = Path("pools"),
|
|
407
|
+
scope: ScopeOption = "team",
|
|
408
|
+
api_endpoint: ApiEndpointOption = DEFAULT_API_ENDPOINT,
|
|
409
|
+
yes: YesOption = False,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Stop the control plane and deregister all matching Cursor pool records."""
|
|
412
|
+
targets = [
|
|
413
|
+
(file, _pool_from_file(file, scope=scope)) for file in _pool_files(pool_file, pools_dir)
|
|
414
|
+
]
|
|
415
|
+
if not yes:
|
|
416
|
+
if not _interactive():
|
|
417
|
+
raise SystemExit("destroy requires --yes when run non-interactively")
|
|
418
|
+
if not _confirm(
|
|
419
|
+
f"Destroy {len(targets)} pool(s)? This stops their apps and deregisters them."
|
|
420
|
+
):
|
|
421
|
+
_console.print("Destroy cancelled")
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
token = os.environ.get("CURSOR_API_KEY")
|
|
425
|
+
if not token and _interactive():
|
|
426
|
+
token = _ask("Cursor service-account API key", password=True)
|
|
427
|
+
if not token:
|
|
428
|
+
raise SystemExit("CURSOR_API_KEY is required to deregister Cursor pools")
|
|
429
|
+
|
|
430
|
+
endpoint = os.environ.get("CURSOR_API_ENDPOINT", api_endpoint)
|
|
431
|
+
try:
|
|
432
|
+
with cursor_client(endpoint, token) as client:
|
|
433
|
+
registry = list_pools(client)
|
|
434
|
+
if not _stop_control_plane_app():
|
|
435
|
+
raise SystemExit("Destroy incomplete; control-plane stop failed")
|
|
436
|
+
failures: list[str] = []
|
|
437
|
+
for file, pool in targets:
|
|
438
|
+
if not _deregister_matches(client, pool, scope, registry):
|
|
439
|
+
failures.append(f"{file} (Cursor deregistration)")
|
|
440
|
+
except (httpx.HTTPError, RegistrySchemaError, ValueError) as error:
|
|
441
|
+
raise SystemExit(
|
|
442
|
+
f"Could not read the Cursor pool registry; nothing was changed: {error}"
|
|
443
|
+
) from error
|
|
444
|
+
|
|
445
|
+
if failures:
|
|
446
|
+
raise SystemExit("Destroy incomplete; failed: " + ", ".join(failures))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@app.command(name="init")
|
|
450
|
+
@instrument("modal_cursor.cli.init")
|
|
451
|
+
def init_pool( # noqa: PLR0912 - one linear CLI workflow with explicit user decisions
|
|
452
|
+
name: PoolNameArg = "",
|
|
453
|
+
*,
|
|
454
|
+
repo_url: RepoUrlOption = "",
|
|
455
|
+
private_repo: Annotated[
|
|
456
|
+
bool, cyclopts.Parameter(help="Configure a GitHub token secret.")
|
|
457
|
+
] = False,
|
|
458
|
+
github_secret_name: Annotated[
|
|
459
|
+
str,
|
|
460
|
+
cyclopts.Parameter(help="Modal Secret containing GITHUB_TOKEN for a private repository."),
|
|
461
|
+
] = "github-token",
|
|
462
|
+
scope: ScopeOption = "team",
|
|
463
|
+
api_endpoint: ApiEndpointOption = DEFAULT_API_ENDPOINT,
|
|
464
|
+
secret_name: SecretNameOption = "cursor-service-account",
|
|
465
|
+
pools_dir: PoolsDirOption = Path("pools"),
|
|
466
|
+
deploy: Annotated[
|
|
467
|
+
bool | None, cyclopts.Parameter(help="Deploy after generating the file.")
|
|
468
|
+
] = None,
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Generate an editable Modal application for one Cursor worker pool."""
|
|
471
|
+
interactive = _interactive()
|
|
472
|
+
if interactive and not _modal_is_configured() and _confirm("Set up Modal now?", default=True):
|
|
473
|
+
subprocess.run([sys.executable, "-m", "modal", "setup"], check=False)
|
|
474
|
+
|
|
475
|
+
while not name:
|
|
476
|
+
if not interactive:
|
|
477
|
+
raise SystemExit("NAME is required")
|
|
478
|
+
name = _ask("Pool name")
|
|
479
|
+
if not secret_name.strip():
|
|
480
|
+
raise SystemExit("secret_name must not be empty")
|
|
481
|
+
if private_repo and not repo_url:
|
|
482
|
+
raise SystemExit("--private-repo requires --repo-url")
|
|
483
|
+
if private_repo and not github_secret_name.strip():
|
|
484
|
+
raise SystemExit("github_secret_name must not be empty for a private repository")
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
pool = Pool(
|
|
488
|
+
name=name,
|
|
489
|
+
repo_url=repo_url or None,
|
|
490
|
+
scope=scope,
|
|
491
|
+
api_endpoint=api_endpoint,
|
|
492
|
+
)
|
|
493
|
+
except (ConfigError, ValidationError) as error:
|
|
494
|
+
raise SystemExit(str(error)) from error
|
|
495
|
+
|
|
496
|
+
out_path = pools_dir / f"{pool.name}.py"
|
|
497
|
+
if out_path.exists():
|
|
498
|
+
raise SystemExit(f"{out_path} already exists, not overwriting")
|
|
499
|
+
worker_secret_names = (github_secret_name,) if private_repo else ()
|
|
500
|
+
pool_options = "".join(
|
|
501
|
+
f", {name}={value!r}"
|
|
502
|
+
for name, value, default in (
|
|
503
|
+
("repo_url", pool.repo_url, None),
|
|
504
|
+
("scope", pool.scope, "team"),
|
|
505
|
+
("api_endpoint", pool.api_endpoint, DEFAULT_API_ENDPOINT),
|
|
506
|
+
)
|
|
507
|
+
if value != default
|
|
508
|
+
)
|
|
509
|
+
generated = Template(TEMPLATE.read_text(encoding="utf-8")).substitute(
|
|
510
|
+
pool_name=repr(pool.name),
|
|
511
|
+
pool_options=pool_options,
|
|
512
|
+
app_name=repr(pool.app_name),
|
|
513
|
+
secret_name=repr(secret_name),
|
|
514
|
+
worker_secret_names=repr(worker_secret_names),
|
|
515
|
+
)
|
|
516
|
+
try:
|
|
517
|
+
pools_dir.mkdir(parents=True, exist_ok=True)
|
|
518
|
+
out_path.write_text(generated, encoding="utf-8")
|
|
519
|
+
except OSError as error:
|
|
520
|
+
raise SystemExit(f"Failed to write {out_path}: {error}") from error
|
|
521
|
+
_ok(f"Wrote {out_path}")
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
existing_secrets: set[str] = _secret_names()
|
|
525
|
+
except modal.exception.Error as error:
|
|
526
|
+
_warn(f"Could not inspect Modal secrets: {error}")
|
|
527
|
+
existing_secrets = set()
|
|
528
|
+
|
|
529
|
+
_ensure_modal_secret(
|
|
530
|
+
name=secret_name,
|
|
531
|
+
key="CURSOR_API_KEY",
|
|
532
|
+
prompt=f"Cursor service-account key ({CURSOR_DOCS_URL})",
|
|
533
|
+
existing=existing_secrets,
|
|
534
|
+
interactive=interactive,
|
|
535
|
+
value=os.environ.get("CURSOR_API_KEY"),
|
|
536
|
+
)
|
|
537
|
+
if private_repo:
|
|
538
|
+
_ensure_modal_secret(
|
|
539
|
+
name=github_secret_name,
|
|
540
|
+
key="GITHUB_TOKEN",
|
|
541
|
+
prompt="GitHub token for the private repository",
|
|
542
|
+
existing=existing_secrets,
|
|
543
|
+
interactive=interactive,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
should_deploy = (
|
|
547
|
+
deploy
|
|
548
|
+
if deploy is not None
|
|
549
|
+
else interactive and _confirm(f"Deploy {pool.name} now?", default=True)
|
|
550
|
+
)
|
|
551
|
+
if should_deploy:
|
|
552
|
+
if not _deploy_and_start([out_path]):
|
|
553
|
+
raise SystemExit(1)
|
|
554
|
+
else:
|
|
555
|
+
_console.print(f"Deploy with: modal-cursor deploy {out_path}")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@app.command
|
|
559
|
+
@instrument("modal_cursor.cli.doctor")
|
|
560
|
+
def doctor(
|
|
561
|
+
*,
|
|
562
|
+
pools_dir: PoolsDirOption = Path("pools"),
|
|
563
|
+
scope: ScopeOption = "team",
|
|
564
|
+
api_endpoint: ApiEndpointOption = DEFAULT_API_ENDPOINT,
|
|
565
|
+
) -> None:
|
|
566
|
+
"""Verify credentials, secrets, the control-plane runner, and registrations."""
|
|
567
|
+
failures = 0
|
|
568
|
+
if _modal_is_configured():
|
|
569
|
+
_ok("Modal credentials are valid")
|
|
570
|
+
else:
|
|
571
|
+
_error("Modal credentials are missing or invalid; run `modal setup`")
|
|
572
|
+
failures += 1
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
secret_names: set[str] = _secret_names()
|
|
576
|
+
except modal.exception.Error as error:
|
|
577
|
+
_error(f"Could not inspect Modal secrets: {error}")
|
|
578
|
+
secret_names = set()
|
|
579
|
+
failures += 1
|
|
580
|
+
|
|
581
|
+
pool_files = sorted(pools_dir.glob("*.py"))
|
|
582
|
+
local_pools: list[tuple[Path, Pool]] = []
|
|
583
|
+
for pool_file in pool_files:
|
|
584
|
+
try:
|
|
585
|
+
pool = _pool_from_file(pool_file, scope=scope)
|
|
586
|
+
except (SystemExit, ConfigError) as error:
|
|
587
|
+
_error(str(error))
|
|
588
|
+
failures += 1
|
|
589
|
+
continue
|
|
590
|
+
local_pools.append((pool_file, pool))
|
|
591
|
+
try:
|
|
592
|
+
failures += _check_local_pool(pool_file, pool, secret_names)
|
|
593
|
+
except ConfigError as error:
|
|
594
|
+
_error(str(error))
|
|
595
|
+
failures += 1
|
|
596
|
+
|
|
597
|
+
if local_pools:
|
|
598
|
+
failures += _check_control_plane()
|
|
599
|
+
|
|
600
|
+
token = os.environ.get("CURSOR_API_KEY")
|
|
601
|
+
registry: list[RegisteredPool] | None = None
|
|
602
|
+
if token:
|
|
603
|
+
endpoint = os.environ.get("CURSOR_API_ENDPOINT", api_endpoint)
|
|
604
|
+
try:
|
|
605
|
+
with cursor_client(endpoint, token) as client:
|
|
606
|
+
registry = list_pools(client)
|
|
607
|
+
except (httpx.HTTPError, RegistrySchemaError, ValueError) as error:
|
|
608
|
+
_error(f"Could not read Cursor pool registry: {error}")
|
|
609
|
+
failures += 1
|
|
610
|
+
elif local_pools:
|
|
611
|
+
_warn("CURSOR_API_KEY is not set; skipped Cursor registry and worker checks")
|
|
612
|
+
|
|
613
|
+
if registry is not None:
|
|
614
|
+
failures += _check_registry(local_pools, registry, scope)
|
|
615
|
+
|
|
616
|
+
if failures:
|
|
617
|
+
raise SystemExit(1)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def run() -> None:
|
|
621
|
+
app()
|