exedev 0.2.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.
- exedev/__init__.py +66 -0
- exedev/_async.py +2707 -0
- exedev/_errors.py +344 -0
- exedev/_sync.py +2701 -0
- exedev/_wire.py +884 -0
- exedev/py.typed +0 -0
- exedev/types.py +536 -0
- exedev-0.2.0.dist-info/METADATA +295 -0
- exedev-0.2.0.dist-info/RECORD +11 -0
- exedev-0.2.0.dist-info/WHEEL +4 -0
- exedev-0.2.0.dist-info/licenses/LICENSE +21 -0
exedev/_async.py
ADDED
|
@@ -0,0 +1,2707 @@
|
|
|
1
|
+
# Code generated by "go run ./cmd/gensdkpy" in the exe.dev repository. DO NOT EDIT.
|
|
2
|
+
|
|
3
|
+
"""The asynchronous API: AsyncClient and AsyncVM."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import base64
|
|
9
|
+
import contextlib
|
|
10
|
+
import errno
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import shlex
|
|
16
|
+
import signal
|
|
17
|
+
import stat
|
|
18
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path, PurePosixPath
|
|
21
|
+
from time import monotonic
|
|
22
|
+
from types import TracebackType
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from . import types as t
|
|
29
|
+
from ._errors import (
|
|
30
|
+
APIError,
|
|
31
|
+
CommandError,
|
|
32
|
+
CommandInterruptedError,
|
|
33
|
+
CommandTimeoutError,
|
|
34
|
+
ExeError,
|
|
35
|
+
NetworkError,
|
|
36
|
+
NotFoundError,
|
|
37
|
+
OutputLimitError,
|
|
38
|
+
PermissionDeniedError,
|
|
39
|
+
ProcessLostError,
|
|
40
|
+
ProtocolError,
|
|
41
|
+
VMReadyError,
|
|
42
|
+
VMSetupError,
|
|
43
|
+
api_error,
|
|
44
|
+
is_error_document,
|
|
45
|
+
parse_documents,
|
|
46
|
+
ssh_error,
|
|
47
|
+
)
|
|
48
|
+
from ._wire import (
|
|
49
|
+
DEFAULT_BASE_URL,
|
|
50
|
+
DOWNLOAD_SCRIPT,
|
|
51
|
+
HEADERS,
|
|
52
|
+
IS_DIRECTORY,
|
|
53
|
+
KILL_SCRIPT,
|
|
54
|
+
LIST_SCRIPT,
|
|
55
|
+
LOGS_SCRIPT,
|
|
56
|
+
MAX_OUTPUT,
|
|
57
|
+
MISSING,
|
|
58
|
+
PARALLEL,
|
|
59
|
+
PART_SCRIPT,
|
|
60
|
+
PART_SIZE,
|
|
61
|
+
READ_SCRIPT,
|
|
62
|
+
RETRYABLE,
|
|
63
|
+
RUN_SCRIPT,
|
|
64
|
+
RUNNING,
|
|
65
|
+
SETUP_PROBE,
|
|
66
|
+
SPAWN_SCRIPT,
|
|
67
|
+
STATE_SCRIPT,
|
|
68
|
+
TIMEOUT,
|
|
69
|
+
UNLIMITED,
|
|
70
|
+
UNTAR_SCRIPT,
|
|
71
|
+
UPLOADED_COMMAND,
|
|
72
|
+
WAIT_SCRIPT,
|
|
73
|
+
WRITE_SCRIPT,
|
|
74
|
+
Arg,
|
|
75
|
+
CommandResult,
|
|
76
|
+
Flag,
|
|
77
|
+
Logs,
|
|
78
|
+
Output,
|
|
79
|
+
ProcessState,
|
|
80
|
+
SetupFailure,
|
|
81
|
+
Stream,
|
|
82
|
+
VMInfo,
|
|
83
|
+
check_ready,
|
|
84
|
+
command_line,
|
|
85
|
+
creates,
|
|
86
|
+
env_flags,
|
|
87
|
+
file_mode,
|
|
88
|
+
fits,
|
|
89
|
+
is_ndjson,
|
|
90
|
+
on_off,
|
|
91
|
+
pack,
|
|
92
|
+
process_name,
|
|
93
|
+
process_state,
|
|
94
|
+
ready_timeout,
|
|
95
|
+
remote_path,
|
|
96
|
+
required,
|
|
97
|
+
retry_delay,
|
|
98
|
+
rm_result,
|
|
99
|
+
setup_state,
|
|
100
|
+
sh,
|
|
101
|
+
signal_name,
|
|
102
|
+
ssh_command,
|
|
103
|
+
stream_timeout,
|
|
104
|
+
tar_directory,
|
|
105
|
+
untar,
|
|
106
|
+
vm_info,
|
|
107
|
+
vm_list,
|
|
108
|
+
vm_name,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
__all__ = ["AsyncClient", "AsyncProcess", "AsyncVM"]
|
|
112
|
+
|
|
113
|
+
OutputCallback = Callable[[str], object]
|
|
114
|
+
"""Called with each line of output as it arrives, decoded as UTF-8, without its line ending. May be a coroutine function."""
|
|
115
|
+
|
|
116
|
+
WaitUntil = Literal["ssh", "setup", "created"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _AsyncTransport:
|
|
120
|
+
"""Runs exe.dev commands over HTTPS: POST /exec with the command line as the body."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, token: str, base_url: str, http_client: httpx.AsyncClient | None, max_retries: int) -> None:
|
|
123
|
+
if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
|
|
124
|
+
raise TypeError("max_retries must be a nonnegative int")
|
|
125
|
+
self._url = base_url.rstrip("/") + "/exec"
|
|
126
|
+
self._headers = {**HEADERS, "Authorization": f"Bearer {token}"}
|
|
127
|
+
self._owned = http_client is None
|
|
128
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient(timeout=TIMEOUT)
|
|
129
|
+
self._retries = max_retries
|
|
130
|
+
|
|
131
|
+
async def aclose(self) -> None:
|
|
132
|
+
if self._owned:
|
|
133
|
+
await self._http.aclose()
|
|
134
|
+
|
|
135
|
+
async def _send(self, command: str, timeout: httpx.Timeout = TIMEOUT, stream: bool = False) -> httpx.Response:
|
|
136
|
+
"""POST command, retrying while the server says 429 (before running anything)."""
|
|
137
|
+
attempt = 0
|
|
138
|
+
while True:
|
|
139
|
+
request = self._http.build_request(
|
|
140
|
+
"POST", self._url, content=command.encode(), headers=self._headers, timeout=timeout
|
|
141
|
+
)
|
|
142
|
+
response = await self._http.send(request, stream=stream)
|
|
143
|
+
if response.status_code != 429 or attempt >= self._retries:
|
|
144
|
+
return response
|
|
145
|
+
await response.aclose()
|
|
146
|
+
await asyncio.sleep(retry_delay(response, attempt))
|
|
147
|
+
attempt += 1
|
|
148
|
+
|
|
149
|
+
async def documents(
|
|
150
|
+
self, path: str, args: Sequence[Arg] = (), flags: Sequence[Flag] = ()
|
|
151
|
+
) -> tuple[int, str, list[Any] | None]:
|
|
152
|
+
"""A successful response's status, body, and JSON documents, including in-band error documents."""
|
|
153
|
+
try:
|
|
154
|
+
response = await self._send(command_line(path, args, flags))
|
|
155
|
+
except httpx.RequestError as err:
|
|
156
|
+
raise NetworkError(path, err) from err
|
|
157
|
+
body = response.text
|
|
158
|
+
if not response.is_success:
|
|
159
|
+
raise api_error(path, response.status_code, body)
|
|
160
|
+
return response.status_code, body, parse_documents(body)
|
|
161
|
+
|
|
162
|
+
async def call(self, path: str, args: Sequence[Arg] = (), flags: Sequence[Flag] = (), json: bool = False) -> Any:
|
|
163
|
+
"""Run a command: its JSON document, or its text output if it is not JSON and json is false."""
|
|
164
|
+
status, body, docs = await self.documents(path, args, flags)
|
|
165
|
+
if docs is None:
|
|
166
|
+
if json:
|
|
167
|
+
raise ProtocolError(path)
|
|
168
|
+
return body
|
|
169
|
+
if any(is_error_document(d) for d in docs):
|
|
170
|
+
raise api_error(path, status, body)
|
|
171
|
+
return docs[0] if len(docs) == 1 else docs
|
|
172
|
+
|
|
173
|
+
async def ssh(
|
|
174
|
+
self,
|
|
175
|
+
vm: str,
|
|
176
|
+
command: str,
|
|
177
|
+
*,
|
|
178
|
+
capture: bool = True,
|
|
179
|
+
max_output: int = MAX_OUTPUT,
|
|
180
|
+
on_stdout: OutputCallback | None = None,
|
|
181
|
+
on_stderr: OutputCallback | None = None,
|
|
182
|
+
timeout: float | None = None,
|
|
183
|
+
) -> CommandResult:
|
|
184
|
+
output = Output(capture, max_output)
|
|
185
|
+
if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0):
|
|
186
|
+
raise ValueError("timeout must be a positive number of seconds")
|
|
187
|
+
deadline = None if timeout is None else monotonic() + timeout
|
|
188
|
+
callbacks = {"stdout": on_stdout, "stderr": on_stderr}
|
|
189
|
+
|
|
190
|
+
def expired() -> bool:
|
|
191
|
+
return deadline is not None and monotonic() >= deadline
|
|
192
|
+
|
|
193
|
+
def timed_out() -> CommandInterruptedError:
|
|
194
|
+
return output.interrupted(CommandTimeoutError, "the command did not finish within its timeout")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
response = await self._send(
|
|
198
|
+
f"ssh {shlex.quote(vm)} {shlex.quote(command)}", stream_timeout(timeout), stream=True
|
|
199
|
+
)
|
|
200
|
+
except httpx.RequestError as err:
|
|
201
|
+
# The request may have reached the VM: whether the command ran is unknown.
|
|
202
|
+
raise (timed_out() if expired() else output.interrupted()) from err
|
|
203
|
+
try:
|
|
204
|
+
if not response.is_success:
|
|
205
|
+
try:
|
|
206
|
+
await response.aread()
|
|
207
|
+
except httpx.RequestError as err:
|
|
208
|
+
raise NetworkError("ssh", err) from err
|
|
209
|
+
raise ssh_error(vm, response.status_code, response.text)
|
|
210
|
+
if not is_ndjson(response):
|
|
211
|
+
# An older server may be streaming the command's raw output, which
|
|
212
|
+
# can be endless: do not read it as an error, or claim the command
|
|
213
|
+
# did not run.
|
|
214
|
+
raise ProtocolError("ssh")
|
|
215
|
+
chunks = response.aiter_bytes()
|
|
216
|
+
while True:
|
|
217
|
+
try:
|
|
218
|
+
chunk = await anext(chunks)
|
|
219
|
+
except StopAsyncIteration:
|
|
220
|
+
break
|
|
221
|
+
except httpx.RequestError as err:
|
|
222
|
+
raise (timed_out() if expired() else output.interrupted()) from err
|
|
223
|
+
for stream, data in output.feed(chunk):
|
|
224
|
+
await _deliver(callbacks[stream], output, stream, data)
|
|
225
|
+
if expired():
|
|
226
|
+
raise timed_out()
|
|
227
|
+
for stream, data in output.finish():
|
|
228
|
+
await _deliver(callbacks[stream], output, stream, data)
|
|
229
|
+
for stream in ("stdout", "stderr"):
|
|
230
|
+
await _deliver(callbacks[stream], output, stream, b"", final=True)
|
|
231
|
+
return output.result()
|
|
232
|
+
finally:
|
|
233
|
+
# Before the end of the body, this ends the command.
|
|
234
|
+
await response.aclose()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def _deliver(
|
|
238
|
+
callback: OutputCallback | None, output: Output, stream: Stream, data: bytes, final: bool = False
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Call callback with each line that data completes."""
|
|
241
|
+
if callback is None:
|
|
242
|
+
return
|
|
243
|
+
for line in output.lines(stream, data, final):
|
|
244
|
+
result = callback(line)
|
|
245
|
+
if inspect.isawaitable(result):
|
|
246
|
+
await result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class AsyncVM:
|
|
250
|
+
"""A VM on exe.dev.
|
|
251
|
+
|
|
252
|
+
Get one from AsyncClient.new(), AsyncClient.vms(), AsyncClient.vm(), or
|
|
253
|
+
AsyncVM.copy(). Its attributes are a snapshot: the SDK's own operations
|
|
254
|
+
keep them current, and refresh() fetches the rest.
|
|
255
|
+
|
|
256
|
+
A VM made by new() or copy() is a context manager that deletes it on
|
|
257
|
+
exit::
|
|
258
|
+
|
|
259
|
+
async with exe.new() as vm:
|
|
260
|
+
await vm.run("make test", check=True)
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def __init__(self, transport: _AsyncTransport, info: VMInfo, *, owned: bool = False) -> None:
|
|
264
|
+
self._t = transport
|
|
265
|
+
self._info = info
|
|
266
|
+
self._owned = owned
|
|
267
|
+
self.domain = _AsyncVMDomain(self)
|
|
268
|
+
self.share = _AsyncVMShare(self)
|
|
269
|
+
self.shelley = _AsyncVMShelley(self)
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def name(self) -> str:
|
|
273
|
+
return self._info.name
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def status(self) -> str:
|
|
277
|
+
"""Such as "starting", "running", or "paused", when last seen."""
|
|
278
|
+
return self._info.status
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def ssh_dest(self) -> str:
|
|
282
|
+
"""The SSH destination, such as "myvm.exe.xyz"."""
|
|
283
|
+
return self._info.ssh_dest
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def https_url(self) -> str:
|
|
287
|
+
"""The VM's web URL, such as "https://myvm.exe.xyz/". url() makes others."""
|
|
288
|
+
return self._info.https_url
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def tags(self) -> tuple[str, ...]:
|
|
292
|
+
return self._info.tags
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def comment(self) -> str:
|
|
296
|
+
"""The VM's comment; "" when it has none."""
|
|
297
|
+
return self._info.comment
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def region(self) -> str | None:
|
|
301
|
+
"""The VM's region; None until listed (see refresh())."""
|
|
302
|
+
return self._info.region
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def created_at(self) -> datetime | None:
|
|
306
|
+
"""When the VM was created; None until listed (see refresh())."""
|
|
307
|
+
return self._info.created_at
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def prompt_error(self) -> str | None:
|
|
311
|
+
"""Why new(prompt=...) did not reach Shelley, when it did not."""
|
|
312
|
+
return self._info.prompt_error
|
|
313
|
+
|
|
314
|
+
def url(self, port: int | None = None, path: str = "/") -> str:
|
|
315
|
+
"""A URL that reaches the VM through exe.dev's HTTPS proxy.
|
|
316
|
+
|
|
317
|
+
Without a port, it is https_url, which reaches the VM's default port
|
|
318
|
+
(see share.port()). With one, it reaches that port; the proxy serves
|
|
319
|
+
ports 3000 to 9999. Who may visit depends on how the VM is shared.
|
|
320
|
+
"""
|
|
321
|
+
if port is not None and (not isinstance(port, int) or isinstance(port, bool) or not 0 < port < 65536):
|
|
322
|
+
raise ValueError("port must be an int from 1 to 65535")
|
|
323
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
324
|
+
raise ValueError("path must be a str starting with /")
|
|
325
|
+
url = urlsplit(self.https_url)
|
|
326
|
+
netloc = url.netloc if port is None else f"{url.hostname}:{port}"
|
|
327
|
+
return urlunsplit((url.scheme, netloc, path, "", ""))
|
|
328
|
+
|
|
329
|
+
def __repr__(self) -> str:
|
|
330
|
+
return f"<{type(self).__name__} {self.name!r} status={self.status!r}>"
|
|
331
|
+
|
|
332
|
+
async def __aenter__(self) -> AsyncVM:
|
|
333
|
+
if not self._owned:
|
|
334
|
+
raise TypeError("only a VM made by new() or copy() deletes itself at the end of a with block")
|
|
335
|
+
return self
|
|
336
|
+
|
|
337
|
+
async def __aexit__(
|
|
338
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
339
|
+
) -> None:
|
|
340
|
+
with contextlib.suppress(NotFoundError):
|
|
341
|
+
await self.delete()
|
|
342
|
+
|
|
343
|
+
def _absorb(self, result: object) -> None:
|
|
344
|
+
"""Take the status, comment, and tags a command reports for this VM into the snapshot."""
|
|
345
|
+
if not isinstance(result, dict) or result.get("vm_name") != self.name:
|
|
346
|
+
return
|
|
347
|
+
if isinstance(status := result.get("status"), str):
|
|
348
|
+
self._info.status = status
|
|
349
|
+
if isinstance(comment := result.get("comment"), str):
|
|
350
|
+
self._info.comment = comment
|
|
351
|
+
tags = result.get("tags")
|
|
352
|
+
if isinstance(tags, list) and all(isinstance(tag, str) for tag in tags):
|
|
353
|
+
self._info.tags = tuple(tags)
|
|
354
|
+
|
|
355
|
+
def _renamed(self, result: Mapping[str, object]) -> None:
|
|
356
|
+
"""Take a new name, and the destinations that change with it."""
|
|
357
|
+
ssh_dest, https_url = result.get("ssh_dest"), result.get("https_url")
|
|
358
|
+
if not isinstance(ssh_dest, str) or not ssh_dest or not isinstance(https_url, str) or not https_url:
|
|
359
|
+
raise ProtocolError("rename")
|
|
360
|
+
self._info.name = vm_name(result["new_name"])
|
|
361
|
+
self._info.ssh_dest = ssh_dest
|
|
362
|
+
self._info.https_url = https_url
|
|
363
|
+
|
|
364
|
+
async def run(
|
|
365
|
+
self,
|
|
366
|
+
command: str | Sequence[str],
|
|
367
|
+
*,
|
|
368
|
+
check: bool = False,
|
|
369
|
+
timeout: float | None = None,
|
|
370
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
371
|
+
env: Mapping[str, str] | None = None,
|
|
372
|
+
on_stdout: OutputCallback | None = None,
|
|
373
|
+
on_stderr: OutputCallback | None = None,
|
|
374
|
+
capture: bool = True,
|
|
375
|
+
max_output: int = MAX_OUTPUT,
|
|
376
|
+
) -> CommandResult:
|
|
377
|
+
"""Run a command on the VM, once, and return how it ended.
|
|
378
|
+
|
|
379
|
+
A string is a shell command line, which the VM user's login shell
|
|
380
|
+
runs as a login shell, so PATH is as in a terminal. A sequence of
|
|
381
|
+
strings is an argument list, which is quoted for you:
|
|
382
|
+
``vm.run(["cat", path])``.
|
|
383
|
+
|
|
384
|
+
Like subprocess.run, a nonzero exit status is a result, not an
|
|
385
|
+
exception, unless check is true; then it raises CommandError.
|
|
386
|
+
|
|
387
|
+
cwd is the directory to run it in, and env variables to set for it.
|
|
388
|
+
|
|
389
|
+
on_stdout and on_stderr are called with each line of output, without
|
|
390
|
+
its line ending, as it arrives; ``on_stdout=print`` works. They are
|
|
391
|
+
called one at a time, and may be coroutine functions. An exception
|
|
392
|
+
from one stops the command and propagates.
|
|
393
|
+
Output is also captured, up to max_output bytes in all (8 MiB by
|
|
394
|
+
default; more raises OutputLimitError). For long or large output,
|
|
395
|
+
pass capture=False and stream it.
|
|
396
|
+
|
|
397
|
+
timeout is in seconds; a command still running then is stopped,
|
|
398
|
+
with every process it started, and CommandTimeoutError raised.
|
|
399
|
+
Without a timeout, a command may run for as long as it likes.
|
|
400
|
+
|
|
401
|
+
run() returns once the command has exited and its output has
|
|
402
|
+
closed, so a process left running in the background with stdout or
|
|
403
|
+
stderr open keeps it waiting. Start those with spawn().
|
|
404
|
+
|
|
405
|
+
If the connection breaks first, CommandInterruptedError: whether the
|
|
406
|
+
command finished is unknown. A command line too long for one request
|
|
407
|
+
is uploaded as a file first. Requires ssh permission.
|
|
408
|
+
"""
|
|
409
|
+
script = await self._fit(ssh_command(command, cwd, env), lambda line: sh(RUN_SCRIPT, line))
|
|
410
|
+
result = await self._t.ssh(
|
|
411
|
+
self.name,
|
|
412
|
+
script,
|
|
413
|
+
capture=capture,
|
|
414
|
+
max_output=max_output,
|
|
415
|
+
on_stdout=on_stdout,
|
|
416
|
+
on_stderr=on_stderr,
|
|
417
|
+
timeout=timeout,
|
|
418
|
+
)
|
|
419
|
+
if check and result.exit_code != 0:
|
|
420
|
+
raise CommandError(result)
|
|
421
|
+
return result
|
|
422
|
+
|
|
423
|
+
async def _fit(self, line: str, make: Callable[[str], str]) -> str:
|
|
424
|
+
"""make(line), or if that does not fit in a request, make() of a
|
|
425
|
+
command line that runs line from a file uploaded first."""
|
|
426
|
+
command = make(line)
|
|
427
|
+
if fits(self.name, command):
|
|
428
|
+
return command
|
|
429
|
+
path = f"/tmp/.exedev-command-{secrets.token_hex(8)}"
|
|
430
|
+
await self.write_file(path, line)
|
|
431
|
+
return make(UPLOADED_COMMAND.format(path=path))
|
|
432
|
+
|
|
433
|
+
async def _script(self, command: str, max_output: int = MAX_OUTPUT, ok: Sequence[int] = ()) -> CommandResult:
|
|
434
|
+
"""Run one of the SDK's scripts. An exit status other than 0 or those in ok raises CommandError."""
|
|
435
|
+
result = await self._t.ssh(self.name, command, max_output=max_output)
|
|
436
|
+
if result.exit_code != 0 and result.exit_code not in ok:
|
|
437
|
+
raise CommandError(result)
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
async def _parallel(self, jobs: Sequence[Callable[[], Awaitable[object]]]) -> None:
|
|
441
|
+
"""Run jobs, PARALLEL at a time. The first to fail stops the rest."""
|
|
442
|
+
semaphore = asyncio.Semaphore(PARALLEL)
|
|
443
|
+
|
|
444
|
+
async def one(job: Callable[[], Awaitable[object]]) -> None:
|
|
445
|
+
async with semaphore:
|
|
446
|
+
await job()
|
|
447
|
+
|
|
448
|
+
tasks = [asyncio.ensure_future(one(job)) for job in jobs]
|
|
449
|
+
try:
|
|
450
|
+
await asyncio.gather(*tasks)
|
|
451
|
+
finally:
|
|
452
|
+
for task in tasks:
|
|
453
|
+
task.cancel()
|
|
454
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
455
|
+
|
|
456
|
+
def _file_error(self, exit_code: int, path: object) -> None:
|
|
457
|
+
if exit_code == MISSING:
|
|
458
|
+
raise FileNotFoundError(errno.ENOENT, f"No such file or directory on VM {self.name}", str(path))
|
|
459
|
+
if exit_code == IS_DIRECTORY:
|
|
460
|
+
raise IsADirectoryError(errno.EISDIR, f"Is a directory on VM {self.name}", str(path))
|
|
461
|
+
|
|
462
|
+
async def write_file(self, path: str | PurePosixPath, data: str | bytes, *, mode: int | None = None) -> None:
|
|
463
|
+
"""Write a file on the VM, replacing it, and making its directory if need be.
|
|
464
|
+
|
|
465
|
+
path is relative to the home directory unless it is absolute. A str
|
|
466
|
+
is written as UTF-8. mode, such as 0o755, sets the file's permissions.
|
|
467
|
+
|
|
468
|
+
A request carries at most 64 KB, so a large file goes in parts,
|
|
469
|
+
several at a time. The VM needs base64, and gzip for a large file.
|
|
470
|
+
Requires ssh permission.
|
|
471
|
+
"""
|
|
472
|
+
remote = remote_path(path)
|
|
473
|
+
packed, gz = pack(data)
|
|
474
|
+
encoded = base64.b64encode(packed).decode()
|
|
475
|
+
options = (file_mode(mode), "gz" if gz else "")
|
|
476
|
+
command = sh(WRITE_SCRIPT, remote, encoded, *options, "")
|
|
477
|
+
if fits(self.name, command):
|
|
478
|
+
await self._script(command)
|
|
479
|
+
return
|
|
480
|
+
parts = f"/tmp/.exedev-upload-{secrets.token_hex(8)}"
|
|
481
|
+
try:
|
|
482
|
+
await self._parallel(
|
|
483
|
+
[
|
|
484
|
+
functools.partial(self._script, sh(PART_SCRIPT, parts, f"{i:06d}", encoded[start : start + PART_SIZE]))
|
|
485
|
+
for i, start in enumerate(range(0, len(encoded), PART_SIZE))
|
|
486
|
+
]
|
|
487
|
+
)
|
|
488
|
+
except Exception:
|
|
489
|
+
with contextlib.suppress(ExeError):
|
|
490
|
+
await self._t.ssh(self.name, f"rm -rf {parts}", capture=False, timeout=30)
|
|
491
|
+
raise
|
|
492
|
+
await self._script(sh(WRITE_SCRIPT, remote, "", *options, parts))
|
|
493
|
+
|
|
494
|
+
async def read_file(self, path: str | PurePosixPath) -> bytes:
|
|
495
|
+
"""The contents of a file on the VM, which raises FileNotFoundError if
|
|
496
|
+
there is none. path is relative to the home directory unless it is
|
|
497
|
+
absolute. Requires ssh permission."""
|
|
498
|
+
result = await self._script(sh(READ_SCRIPT, remote_path(path)), UNLIMITED, ok=(MISSING, IS_DIRECTORY))
|
|
499
|
+
self._file_error(result.exit_code, path)
|
|
500
|
+
return result.stdout_bytes
|
|
501
|
+
|
|
502
|
+
async def upload(
|
|
503
|
+
self, local: str | os.PathLike[str], remote: str | PurePosixPath, *, exclude: Sequence[str] = ()
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Copy a local file or directory to the VM.
|
|
506
|
+
|
|
507
|
+
A file is written to remote, with its permissions. A directory's
|
|
508
|
+
contents are copied into remote, which is made if need be; exclude
|
|
509
|
+
leaves out files and directories whose names match any of its
|
|
510
|
+
patterns, such as ``[".git", "node_modules", "*.pyc"]``. The VM needs
|
|
511
|
+
tar for a directory. Requires ssh permission.
|
|
512
|
+
"""
|
|
513
|
+
source = Path(local)
|
|
514
|
+
destination = remote_path(remote)
|
|
515
|
+
if source.is_dir():
|
|
516
|
+
archive = await asyncio.to_thread(tar_directory, source, exclude)
|
|
517
|
+
temporary = f"/tmp/.exedev-upload-{secrets.token_hex(8)}.tgz"
|
|
518
|
+
await self.write_file(temporary, archive)
|
|
519
|
+
await self._script(sh(UNTAR_SCRIPT, temporary, destination))
|
|
520
|
+
else:
|
|
521
|
+
await self.write_file(destination, source.read_bytes(), mode=stat.S_IMODE(source.stat().st_mode))
|
|
522
|
+
|
|
523
|
+
async def download(self, remote: str | PurePosixPath, local: str | os.PathLike[str]) -> None:
|
|
524
|
+
"""Copy a file or directory from the VM.
|
|
525
|
+
|
|
526
|
+
A file is written to local. A directory's contents are copied into
|
|
527
|
+
local, which is made if need be. A path on the VM that does not
|
|
528
|
+
exist raises FileNotFoundError. The VM needs tar for a directory.
|
|
529
|
+
Requires ssh permission.
|
|
530
|
+
"""
|
|
531
|
+
result = await self._script(sh(DOWNLOAD_SCRIPT, remote_path(remote)), UNLIMITED, ok=(MISSING,))
|
|
532
|
+
self._file_error(result.exit_code, remote)
|
|
533
|
+
kind, data = result.stdout_bytes[:1], result.stdout_bytes[1:]
|
|
534
|
+
destination = Path(local)
|
|
535
|
+
if kind == b"D":
|
|
536
|
+
await asyncio.to_thread(untar, data, destination)
|
|
537
|
+
elif kind == b"F":
|
|
538
|
+
destination.write_bytes(data)
|
|
539
|
+
else:
|
|
540
|
+
raise ProtocolError("download")
|
|
541
|
+
|
|
542
|
+
@creates
|
|
543
|
+
async def spawn(
|
|
544
|
+
self,
|
|
545
|
+
command: str | Sequence[str],
|
|
546
|
+
*,
|
|
547
|
+
name: str | None = None,
|
|
548
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
549
|
+
env: Mapping[str, str] | None = None,
|
|
550
|
+
) -> AsyncProcess:
|
|
551
|
+
"""Start a command in the background, and return at once.
|
|
552
|
+
|
|
553
|
+
The command runs as run() runs it, but on its own: until it exits,
|
|
554
|
+
or is killed, whatever becomes of this program or its connection.
|
|
555
|
+
Its output goes to files on the VM; see AsyncProcess.logs().
|
|
556
|
+
|
|
557
|
+
name identifies the process, so that process() finds it again
|
|
558
|
+
later, from anywhere; by default, it is random. It must not be the
|
|
559
|
+
name of a process still running. A process is a context manager that
|
|
560
|
+
kills it on exit::
|
|
561
|
+
|
|
562
|
+
async with vm.spawn("python3 -m http.server 8000") as server:
|
|
563
|
+
...
|
|
564
|
+
|
|
565
|
+
Requires ssh permission, and setsid on the VM.
|
|
566
|
+
"""
|
|
567
|
+
name = process_name(name if name is not None else secrets.token_hex(4))
|
|
568
|
+
script = await self._fit(ssh_command(command, cwd, env), lambda line: sh(SPAWN_SCRIPT, name, line))
|
|
569
|
+
result = await self._script(script, ok=(RUNNING,))
|
|
570
|
+
if result.exit_code == RUNNING:
|
|
571
|
+
raise ExeError(f"a process named {name} is already running on VM {self.name}")
|
|
572
|
+
try:
|
|
573
|
+
pid = int(result.stdout)
|
|
574
|
+
except ValueError:
|
|
575
|
+
raise ProtocolError("spawn") from None
|
|
576
|
+
return AsyncProcess(self, name, ProcessState(pid))
|
|
577
|
+
|
|
578
|
+
async def process(self, name: str) -> AsyncProcess:
|
|
579
|
+
"""The process that spawn() started with this name, running or not.
|
|
580
|
+
ProcessLookupError if there is none. Requires ssh permission."""
|
|
581
|
+
result = await self._script(sh(STATE_SCRIPT, process_name(name)), ok=(MISSING,))
|
|
582
|
+
if result.exit_code == MISSING:
|
|
583
|
+
raise ProcessLookupError(f"no process {name} on VM {self.name}")
|
|
584
|
+
return AsyncProcess(self, name, process_state(result.stdout))
|
|
585
|
+
|
|
586
|
+
async def processes(self) -> list[AsyncProcess]:
|
|
587
|
+
"""The processes spawn() has started, running or not. Requires ssh permission."""
|
|
588
|
+
result = await self._script(sh(LIST_SCRIPT))
|
|
589
|
+
processes = []
|
|
590
|
+
for line in result.stdout.splitlines():
|
|
591
|
+
name, _, state = line.partition(" ")
|
|
592
|
+
processes.append(AsyncProcess(self, process_name(name), process_state(state)))
|
|
593
|
+
return processes
|
|
594
|
+
|
|
595
|
+
async def refresh(self) -> None:
|
|
596
|
+
"""Fetch the VM's attributes. Requires ls permission."""
|
|
597
|
+
self._info = await _find(self._t, self.name)
|
|
598
|
+
|
|
599
|
+
async def wait_until_ready(self, until: Literal["ssh", "setup"] = "ssh", *, timeout: float | None = None) -> None:
|
|
600
|
+
"""Wait until the VM runs commands.
|
|
601
|
+
|
|
602
|
+
It probes SSH with ``true`` until it succeeds. With until="setup", it
|
|
603
|
+
then waits for the VM's setup script (exeuntu's exe-setup.service) to
|
|
604
|
+
finish, raising VMSetupError if it failed. A script on an image
|
|
605
|
+
without that unit (anything not built on exeuntu) never runs: that is
|
|
606
|
+
a VMSetupError too, with result "unsupported". This does not wait for
|
|
607
|
+
servers the VM starts.
|
|
608
|
+
|
|
609
|
+
timeout is in seconds, by default 60, or 600 for setup. Raises
|
|
610
|
+
VMReadyError. Requires ssh permission.
|
|
611
|
+
"""
|
|
612
|
+
seconds = ready_timeout(until, timeout)
|
|
613
|
+
await self._wait(until, seconds)
|
|
614
|
+
|
|
615
|
+
async def _wait(self, until: str, timeout: float) -> None:
|
|
616
|
+
deadline = monotonic() + timeout
|
|
617
|
+
backoff = 0.1
|
|
618
|
+
probe = SETUP_PROBE if until == "setup" else "true"
|
|
619
|
+
last: BaseException | None = None
|
|
620
|
+
while (remaining := deadline - monotonic()) > 0:
|
|
621
|
+
try:
|
|
622
|
+
result = await self._t.ssh(
|
|
623
|
+
self.name, probe, capture=until == "setup", max_output=1 << 20, timeout=remaining
|
|
624
|
+
)
|
|
625
|
+
if result.exit_code != 0:
|
|
626
|
+
raise ProtocolError("ssh readiness probe")
|
|
627
|
+
state = setup_state(result.stdout) if until == "setup" else "done"
|
|
628
|
+
if state == "done":
|
|
629
|
+
# It answered over SSH, so it is running, whatever new or cp said then.
|
|
630
|
+
self._info.status = "running"
|
|
631
|
+
return
|
|
632
|
+
if isinstance(state, SetupFailure):
|
|
633
|
+
raise VMSetupError(self, state.result, state.exit_code, state.signal, state.output)
|
|
634
|
+
backoff = max(backoff, 1.0)
|
|
635
|
+
except CommandTimeoutError as err:
|
|
636
|
+
last = err
|
|
637
|
+
break
|
|
638
|
+
except (OutputLimitError, ProtocolError) as err:
|
|
639
|
+
raise VMReadyError(self, "is not SSH-ready") from err
|
|
640
|
+
except CommandInterruptedError as err:
|
|
641
|
+
# The probe is safe to rerun after a lost connection.
|
|
642
|
+
last = err
|
|
643
|
+
except PermissionDeniedError as err:
|
|
644
|
+
raise VMReadyError(
|
|
645
|
+
self,
|
|
646
|
+
"is not known to be ready: this token may not run ssh, "
|
|
647
|
+
'which readiness needs (use wait_until="created")',
|
|
648
|
+
) from err
|
|
649
|
+
except APIError as err:
|
|
650
|
+
# /exec reports these before a remote command starts. Others (not
|
|
651
|
+
# found, no SSH configured) fail fast.
|
|
652
|
+
if err.status != 422 or not RETRYABLE.search(err.body):
|
|
653
|
+
raise VMReadyError(self, "is not SSH-ready") from err
|
|
654
|
+
last = err
|
|
655
|
+
except NetworkError as err:
|
|
656
|
+
last = err
|
|
657
|
+
await asyncio.sleep(max(0.0, min(backoff, deadline - monotonic())))
|
|
658
|
+
backoff = min(backoff * 2, 2.0)
|
|
659
|
+
what = "setup did not finish in time" if until == "setup" else "SSH readiness timed out"
|
|
660
|
+
raise VMReadyError(self, f"is not ready: {what}") from last
|
|
661
|
+
|
|
662
|
+
async def _ready(self, wait_until: WaitUntil, timeout: float | None) -> None:
|
|
663
|
+
if wait_until != "created":
|
|
664
|
+
await self._wait(wait_until, ready_timeout(wait_until, timeout))
|
|
665
|
+
|
|
666
|
+
async def _created(self, wait_until: WaitUntil, timeout: float | None, keep_on_failure: bool) -> AsyncVM:
|
|
667
|
+
"""Wait until a VM that new() or copy() made is ready. If that fails,
|
|
668
|
+
or is interrupted, delete it: the caller never received it."""
|
|
669
|
+
try:
|
|
670
|
+
await self._ready(wait_until, timeout)
|
|
671
|
+
except BaseException as err:
|
|
672
|
+
if not (keep_on_failure and isinstance(err, VMReadyError)):
|
|
673
|
+
with contextlib.suppress(Exception):
|
|
674
|
+
await self.delete()
|
|
675
|
+
raise
|
|
676
|
+
return self
|
|
677
|
+
|
|
678
|
+
async def _flush(self) -> None:
|
|
679
|
+
"""Flush the VM's recent writes to its disk, if it is running. Best effort."""
|
|
680
|
+
with contextlib.suppress(ExeError):
|
|
681
|
+
await self._t.ssh(self.name, "sync", capture=False, timeout=60)
|
|
682
|
+
|
|
683
|
+
async def delete(self) -> None:
|
|
684
|
+
"""Delete the VM. A VM that does not exist raises NotFoundError.
|
|
685
|
+
|
|
686
|
+
CLI: ``rm <vmname>...``
|
|
687
|
+
|
|
688
|
+
Tag-scoped API keys may run this command.
|
|
689
|
+
"""
|
|
690
|
+
_, body, docs = await self._t.documents("rm", [self.name])
|
|
691
|
+
summary = rm_result(docs)
|
|
692
|
+
if summary["failed"] or len(summary["deleted"]) != 1:
|
|
693
|
+
raise api_error("rm", 404 if summary.get("not_found") else 422, body)
|
|
694
|
+
|
|
695
|
+
async def restart(self, *, wait_until: WaitUntil = "ssh", timeout: float | None = None) -> t.RestartResponse:
|
|
696
|
+
"""Restart a VM.
|
|
697
|
+
|
|
698
|
+
CLI: ``restart <vmname>``
|
|
699
|
+
|
|
700
|
+
Tag-scoped API keys may run this command.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
|
|
704
|
+
also waits for its setup script to finish, and succeed; "created"
|
|
705
|
+
returns at once. Waiting needs ssh permission, and raises VMReadyError
|
|
706
|
+
if the VM does not become ready.
|
|
707
|
+
timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
|
|
708
|
+
"""
|
|
709
|
+
check_ready(wait_until, timeout)
|
|
710
|
+
result: t.RestartResponse = await self._t.call("restart", [self.name], json=True)
|
|
711
|
+
await self._ready(wait_until, timeout)
|
|
712
|
+
return result
|
|
713
|
+
|
|
714
|
+
async def pause(self) -> t.PauseResponse:
|
|
715
|
+
"""Pause a VM (snapshot RAM to disk, free its CPU and memory).
|
|
716
|
+
|
|
717
|
+
CLI: ``pause <vmname>``
|
|
718
|
+
|
|
719
|
+
Tag-scoped API keys may run this command.
|
|
720
|
+
"""
|
|
721
|
+
result: t.PauseResponse = await self._t.call("pause", [self.name], json=True)
|
|
722
|
+
self._absorb(result)
|
|
723
|
+
return result
|
|
724
|
+
|
|
725
|
+
async def resume(self, *, wait_until: WaitUntil = "ssh", timeout: float | None = None) -> t.ResumeResponse:
|
|
726
|
+
"""Resume a paused VM (restore RAM from disk).
|
|
727
|
+
|
|
728
|
+
CLI: ``resume <vmname>``
|
|
729
|
+
|
|
730
|
+
Tag-scoped API keys may run this command.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
|
|
734
|
+
also waits for its setup script to finish, and succeed; "created"
|
|
735
|
+
returns at once. Waiting needs ssh permission, and raises VMReadyError
|
|
736
|
+
if the VM does not become ready.
|
|
737
|
+
timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
|
|
738
|
+
"""
|
|
739
|
+
check_ready(wait_until, timeout)
|
|
740
|
+
result: t.ResumeResponse = await self._t.call("resume", [self.name], json=True)
|
|
741
|
+
self._absorb(result)
|
|
742
|
+
await self._ready(wait_until, timeout)
|
|
743
|
+
return result
|
|
744
|
+
|
|
745
|
+
async def rename(self, new_name: str) -> t.RenameResponse:
|
|
746
|
+
"""Rename the VM. Its ssh_dest and https_url change with its name.
|
|
747
|
+
|
|
748
|
+
CLI: ``rename <oldname> <newname>``
|
|
749
|
+
|
|
750
|
+
Tag-scoped API keys may run this command.
|
|
751
|
+
"""
|
|
752
|
+
result: t.RenameResponse = await self._t.call("rename", [self.name, new_name], json=True)
|
|
753
|
+
if not isinstance(result, dict) or result.get("old_name") != self.name or result.get("new_name") != new_name:
|
|
754
|
+
raise ProtocolError("rename")
|
|
755
|
+
self._renamed(result)
|
|
756
|
+
return result
|
|
757
|
+
|
|
758
|
+
async def tag(self, *tag_names: str) -> t.TagResponse:
|
|
759
|
+
"""Add tags to the VM.
|
|
760
|
+
|
|
761
|
+
CLI: ``tag [-d] <vm> <tag-name> [tag-name...]``
|
|
762
|
+
|
|
763
|
+
Tag-scoped API keys may run this command.
|
|
764
|
+
"""
|
|
765
|
+
result: t.TagResponse = await self._t.call("tag", [self.name, required("tag name", tag_names)], json=True)
|
|
766
|
+
self._absorb(result)
|
|
767
|
+
return result
|
|
768
|
+
|
|
769
|
+
async def untag(self, *tag_names: str) -> t.TagResponse:
|
|
770
|
+
"""Remove tags from the VM.
|
|
771
|
+
|
|
772
|
+
CLI: ``tag [-d] <vm> <tag-name> [tag-name...]``
|
|
773
|
+
|
|
774
|
+
Tag-scoped API keys may run this command.
|
|
775
|
+
"""
|
|
776
|
+
result: t.TagResponse = await self._t.call(
|
|
777
|
+
"tag",
|
|
778
|
+
[self.name, required("tag name", tag_names)],
|
|
779
|
+
[("d", "bool", True)],
|
|
780
|
+
json=True,
|
|
781
|
+
)
|
|
782
|
+
self._absorb(result)
|
|
783
|
+
return result
|
|
784
|
+
|
|
785
|
+
@creates
|
|
786
|
+
async def copy(
|
|
787
|
+
self,
|
|
788
|
+
name: str | None = None,
|
|
789
|
+
*,
|
|
790
|
+
copy_tags: bool = True,
|
|
791
|
+
cpu: int | None = None,
|
|
792
|
+
disk: int | str | None = None,
|
|
793
|
+
memory: int | str | None = None,
|
|
794
|
+
pool: str | None = None,
|
|
795
|
+
standalone: bool = False,
|
|
796
|
+
flush: bool = True,
|
|
797
|
+
wait_until: WaitUntil = "ssh",
|
|
798
|
+
timeout: float | None = None,
|
|
799
|
+
keep_on_failure: bool = False,
|
|
800
|
+
) -> AsyncVM:
|
|
801
|
+
"""Copy the VM: a new VM with a copy of its disk, and by default its tags.
|
|
802
|
+
|
|
803
|
+
CLI: ``cp <source-vm> [new-name] [--pool=<name>] [--memory=<size>] [--cpu=<count>] [--disk=<size>]``
|
|
804
|
+
|
|
805
|
+
Tag-scoped API keys may run this command.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
copy_tags: copy tags from source VM (use ``copy_tags=False`` to disable)
|
|
809
|
+
cpu: number of CPUs
|
|
810
|
+
disk: disk size (e.g., 20, 20GB, 50G)
|
|
811
|
+
memory: memory allocation (e.g., 4, 4GB, 8G)
|
|
812
|
+
pool: create the copy in one of your team's pools (see ``exe.pool.list()``)
|
|
813
|
+
standalone: create the copy as a standalone VM with its own reserved
|
|
814
|
+
capacity (requires a plan with standalone VMs and team approval)
|
|
815
|
+
flush: first flush the VM's recent writes to its disk, so that the copy has
|
|
816
|
+
them. Best effort: it needs ssh permission, and a running VM.
|
|
817
|
+
wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
|
|
818
|
+
also waits for its setup script to finish, and succeed; "created"
|
|
819
|
+
returns at once. Waiting needs ssh permission, and raises VMReadyError
|
|
820
|
+
if the VM does not become ready.
|
|
821
|
+
timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
|
|
822
|
+
keep_on_failure: keep a VM that does not become ready, for debugging:
|
|
823
|
+
VMReadyError.vm is it. By default, it is deleted, as it is when the wait
|
|
824
|
+
is interrupted.
|
|
825
|
+
"""
|
|
826
|
+
check_ready(wait_until, timeout)
|
|
827
|
+
if flush:
|
|
828
|
+
await self._flush()
|
|
829
|
+
result = await self._t.call(
|
|
830
|
+
"cp",
|
|
831
|
+
[self.name, name],
|
|
832
|
+
[
|
|
833
|
+
("copy-tags", "bool", None if copy_tags else False),
|
|
834
|
+
("cpu", "number", cpu),
|
|
835
|
+
("disk", "string", disk),
|
|
836
|
+
("memory", "string", memory),
|
|
837
|
+
("pool", "string", pool),
|
|
838
|
+
("standalone", "bool", standalone or None),
|
|
839
|
+
],
|
|
840
|
+
json=True,
|
|
841
|
+
)
|
|
842
|
+
vm = AsyncVM(self._t, vm_info(result, "cp"), owned=True)
|
|
843
|
+
return await vm._created(wait_until, timeout, keep_on_failure)
|
|
844
|
+
|
|
845
|
+
async def resize(
|
|
846
|
+
self,
|
|
847
|
+
*,
|
|
848
|
+
cpu: int | None = None,
|
|
849
|
+
disk: int | str | None = None,
|
|
850
|
+
memory: int | str | None = None,
|
|
851
|
+
restart: bool = False,
|
|
852
|
+
) -> t.ResizeResponse:
|
|
853
|
+
"""Resize the VM's memory, CPUs, or disk. Memory and CPU changes take effect when it restarts.
|
|
854
|
+
|
|
855
|
+
CLI: ``resize <vmname> [--memory=<size>] [--cpu=<count>] [--disk=<size>]``
|
|
856
|
+
|
|
857
|
+
Tag-scoped API keys may run this command.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
cpu: number of CPUs
|
|
861
|
+
disk: new total disk size (e.g., 25, 25GB) - disk size can only increase;
|
|
862
|
+
billed by usage above your plan's included disk, not by size
|
|
863
|
+
memory: memory allocation (e.g., 4, 4GB, 8G)
|
|
864
|
+
restart: restart the VM afterwards, so that memory and cpu changes take
|
|
865
|
+
effect, and wait until it runs commands.
|
|
866
|
+
"""
|
|
867
|
+
result: t.ResizeResponse = await self._t.call(
|
|
868
|
+
"resize",
|
|
869
|
+
[self.name],
|
|
870
|
+
[
|
|
871
|
+
("cpu", "number", cpu),
|
|
872
|
+
("disk", "string", disk),
|
|
873
|
+
("memory", "string", memory),
|
|
874
|
+
],
|
|
875
|
+
json=True,
|
|
876
|
+
)
|
|
877
|
+
if restart:
|
|
878
|
+
await self.restart()
|
|
879
|
+
return result
|
|
880
|
+
|
|
881
|
+
async def set_comment(self, text: str) -> t.CommentResponse:
|
|
882
|
+
"""Set or clear a short comment on a VM.
|
|
883
|
+
|
|
884
|
+
CLI: ``comment <hostname> <text>``
|
|
885
|
+
|
|
886
|
+
Tag-scoped API keys may run this command.
|
|
887
|
+
"""
|
|
888
|
+
result: t.CommentResponse = await self._t.call("comment", [self.name, text], json=True)
|
|
889
|
+
self._absorb(result)
|
|
890
|
+
return result
|
|
891
|
+
|
|
892
|
+
async def grant_support_root(self, enabled: bool) -> Any:
|
|
893
|
+
"""Allow exe.dev support to log in to a VM.
|
|
894
|
+
|
|
895
|
+
CLI: ``grant-support-root <vmname> on|off``
|
|
896
|
+
|
|
897
|
+
Tag-scoped API keys may run this command.
|
|
898
|
+
"""
|
|
899
|
+
return await self._t.call("grant-support-root", [self.name, on_off(enabled)])
|
|
900
|
+
|
|
901
|
+
async def stats(self, *, range: str | None = None) -> t.StatResponse:
|
|
902
|
+
"""Show vCPU, disk, IO, and network (RX/TX) metrics for a VM.
|
|
903
|
+
|
|
904
|
+
CLI: ``stat <vm-name> [--range=24h|7d|30d]``
|
|
905
|
+
|
|
906
|
+
Tag-scoped API keys may run this command.
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
range: time range: 24h, 7d, or 30d (server default: 24h)
|
|
910
|
+
"""
|
|
911
|
+
result: t.StatResponse = await self._t.call("stat", [self.name], [("range", "string", range)], json=True)
|
|
912
|
+
return result
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
async def _find(transport: _AsyncTransport, name: str) -> VMInfo:
|
|
916
|
+
result = await transport.call("ls", [name], json=True)
|
|
917
|
+
for info in vm_list(result):
|
|
918
|
+
if info.name == name:
|
|
919
|
+
return info
|
|
920
|
+
raise NotFoundError("ls", 404, f'{{"error": "VM {name} not found"}}')
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
class AsyncProcess:
|
|
924
|
+
"""A process that AsyncVM.spawn() started on a VM.
|
|
925
|
+
|
|
926
|
+
Like subprocess.Popen, but the process does not depend on this program:
|
|
927
|
+
it runs until it exits or is killed. Find it again later with
|
|
928
|
+
AsyncVM.process(name). Its attributes are a snapshot; poll() and wait()
|
|
929
|
+
update them.
|
|
930
|
+
|
|
931
|
+
As a context manager, it kills the process at the end of the block.
|
|
932
|
+
"""
|
|
933
|
+
|
|
934
|
+
def __init__(self, vm: AsyncVM, name: str, state: ProcessState) -> None:
|
|
935
|
+
self.vm = vm
|
|
936
|
+
"""The VM it runs on."""
|
|
937
|
+
self.name = name
|
|
938
|
+
"""Its name, which AsyncVM.process() finds it by."""
|
|
939
|
+
self._state = state
|
|
940
|
+
|
|
941
|
+
@property
|
|
942
|
+
def pid(self) -> int:
|
|
943
|
+
"""Its process ID on the VM, which is also its process group's."""
|
|
944
|
+
return self._state.pid
|
|
945
|
+
|
|
946
|
+
@property
|
|
947
|
+
def exit_code(self) -> int | None:
|
|
948
|
+
"""Its exit status, or None if it was running when last seen.
|
|
949
|
+
A process killed by a signal has 128 plus the signal's number."""
|
|
950
|
+
return self._state.exit_code
|
|
951
|
+
|
|
952
|
+
def __repr__(self) -> str:
|
|
953
|
+
state = "lost" if self._state.lost else "running" if self.exit_code is None else f"exit_code={self.exit_code}"
|
|
954
|
+
return f"<{type(self).__name__} {self.name!r} on {self.vm.name!r} pid={self.pid} {state}>"
|
|
955
|
+
|
|
956
|
+
async def __aenter__(self) -> AsyncProcess:
|
|
957
|
+
return self
|
|
958
|
+
|
|
959
|
+
async def __aexit__(
|
|
960
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
961
|
+
) -> None:
|
|
962
|
+
with contextlib.suppress(NotFoundError, ProcessLookupError):
|
|
963
|
+
await self.kill()
|
|
964
|
+
|
|
965
|
+
def _check(self) -> int | None:
|
|
966
|
+
if self._state.lost:
|
|
967
|
+
raise ProcessLostError(self)
|
|
968
|
+
return self._state.exit_code
|
|
969
|
+
|
|
970
|
+
async def poll(self) -> int | None:
|
|
971
|
+
"""Its exit status, or None if it is still running. Raises
|
|
972
|
+
ProcessLostError if it ended without one."""
|
|
973
|
+
result = await self.vm._script(sh(STATE_SCRIPT, self.name), ok=(MISSING,))
|
|
974
|
+
if result.exit_code == MISSING:
|
|
975
|
+
raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
|
|
976
|
+
self._state = process_state(result.stdout)
|
|
977
|
+
return self._check()
|
|
978
|
+
|
|
979
|
+
async def wait(
|
|
980
|
+
self, *, timeout: float | None = None, check: bool = False, capture: bool = True, max_output: int = MAX_OUTPUT
|
|
981
|
+
) -> CommandResult:
|
|
982
|
+
"""Wait for it to exit, and return how it ended, with its output.
|
|
983
|
+
|
|
984
|
+
A lost connection does not matter: wait() reconnects until it knows.
|
|
985
|
+
timeout is in seconds: TimeoutError if it is still running then
|
|
986
|
+
(which it still is). check, capture, and max_output are as for
|
|
987
|
+
AsyncVM.run(); with capture=False, the result has no output.
|
|
988
|
+
Raises ProcessLostError if it ended without an exit status.
|
|
989
|
+
"""
|
|
990
|
+
deadline = None if timeout is None else monotonic() + timeout
|
|
991
|
+
nonce = secrets.token_hex(8)
|
|
992
|
+
backoff = 0.1
|
|
993
|
+
while True:
|
|
994
|
+
remaining = None if deadline is None else deadline - monotonic()
|
|
995
|
+
if remaining is not None and remaining <= 0:
|
|
996
|
+
raise TimeoutError(f"process {self.name} on VM {self.vm.name} is still running")
|
|
997
|
+
try:
|
|
998
|
+
command = sh(WAIT_SCRIPT, self.name, nonce, "1" if capture else "")
|
|
999
|
+
result = await self.vm._t.ssh(self.vm.name, command, max_output=max_output, timeout=remaining)
|
|
1000
|
+
break
|
|
1001
|
+
except CommandTimeoutError:
|
|
1002
|
+
continue
|
|
1003
|
+
except (CommandInterruptedError, NetworkError) as err:
|
|
1004
|
+
if isinstance(err, OutputLimitError):
|
|
1005
|
+
raise
|
|
1006
|
+
await asyncio.sleep(backoff)
|
|
1007
|
+
backoff = min(backoff * 2, 2.0)
|
|
1008
|
+
if result.exit_code == 0 and result.stdout == f"{nonce} missing":
|
|
1009
|
+
raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
|
|
1010
|
+
if result.exit_code == 0 and result.stdout == f"{nonce} lost":
|
|
1011
|
+
self._state.lost = True
|
|
1012
|
+
raise ProcessLostError(self)
|
|
1013
|
+
self._state.exit_code = result.exit_code
|
|
1014
|
+
if check and result.exit_code != 0:
|
|
1015
|
+
raise CommandError(result)
|
|
1016
|
+
return result
|
|
1017
|
+
|
|
1018
|
+
async def logs(self, *, tail: int | None = None) -> Logs:
|
|
1019
|
+
"""Its output so far; with tail, at most that many lines of each stream."""
|
|
1020
|
+
if tail is not None and (not isinstance(tail, int) or isinstance(tail, bool) or tail < 0):
|
|
1021
|
+
raise ValueError("tail must be a nonnegative int")
|
|
1022
|
+
result = await self.vm._script(
|
|
1023
|
+
sh(LOGS_SCRIPT, self.name, "" if tail is None else str(tail)), UNLIMITED, ok=(MISSING,)
|
|
1024
|
+
)
|
|
1025
|
+
if result.exit_code == MISSING:
|
|
1026
|
+
raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
|
|
1027
|
+
return Logs(result.stdout, result.stderr)
|
|
1028
|
+
|
|
1029
|
+
async def send_signal(self, sig: int | str) -> None:
|
|
1030
|
+
"""Send a signal, such as signal.SIGTERM or "HUP", to the process and
|
|
1031
|
+
everything it started, if it is running."""
|
|
1032
|
+
result = await self.vm._script(sh(KILL_SCRIPT, self.name, signal_name(sig)), ok=(MISSING,))
|
|
1033
|
+
if result.exit_code == MISSING:
|
|
1034
|
+
raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
|
|
1035
|
+
|
|
1036
|
+
async def terminate(self) -> None:
|
|
1037
|
+
"""Send SIGTERM to the process and everything it started."""
|
|
1038
|
+
await self.send_signal(signal.SIGTERM)
|
|
1039
|
+
|
|
1040
|
+
async def kill(self) -> None:
|
|
1041
|
+
"""Send SIGKILL to the process and everything it started."""
|
|
1042
|
+
await self.send_signal(signal.SIGKILL)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
class AsyncClient:
|
|
1046
|
+
"""An exe.dev API client.
|
|
1047
|
+
|
|
1048
|
+
token is an exe.dev API token; by default, the EXE_TOKEN environment
|
|
1049
|
+
variable. Create one with ``ssh exe.dev ssh-key generate-api-key``. Its
|
|
1050
|
+
permissions decide which methods work.
|
|
1051
|
+
|
|
1052
|
+
http_client is an httpx.AsyncClient to send requests with, for proxies,
|
|
1053
|
+
custom TLS, or tests. The SDK does not close a client it was given.
|
|
1054
|
+
|
|
1055
|
+
A request refused for exceeding the rate limit, which runs nothing, is
|
|
1056
|
+
retried up to max_retries times, with backoff.
|
|
1057
|
+
|
|
1058
|
+
Use it as a context manager, or call aclose(), to release connections.
|
|
1059
|
+
"""
|
|
1060
|
+
|
|
1061
|
+
def __init__(
|
|
1062
|
+
self,
|
|
1063
|
+
token: str | None = None,
|
|
1064
|
+
*,
|
|
1065
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
1066
|
+
http_client: httpx.AsyncClient | None = None,
|
|
1067
|
+
max_retries: int = 5,
|
|
1068
|
+
) -> None:
|
|
1069
|
+
token = token if token is not None else os.environ.get("EXE_TOKEN")
|
|
1070
|
+
if not token:
|
|
1071
|
+
raise ExeError("an exe.dev API token is required: pass token= or set EXE_TOKEN")
|
|
1072
|
+
self._t = _AsyncTransport(token, base_url, http_client, max_retries)
|
|
1073
|
+
self.domain = _AsyncDomain(self._t)
|
|
1074
|
+
self.ssh_key = _AsyncSshKey(self._t)
|
|
1075
|
+
self.integrations = _AsyncIntegrations(self._t)
|
|
1076
|
+
self.team = _AsyncTeam(self._t)
|
|
1077
|
+
self.pool = _AsyncPool(self._t)
|
|
1078
|
+
self.billing = _AsyncBilling(self._t)
|
|
1079
|
+
self.invite = _AsyncInvite(self._t)
|
|
1080
|
+
|
|
1081
|
+
def __repr__(self) -> str:
|
|
1082
|
+
return f"<{type(self).__name__} {self._t._url.removesuffix('/exec')!r}>"
|
|
1083
|
+
|
|
1084
|
+
async def aclose(self) -> None:
|
|
1085
|
+
"""Close the HTTP client this client made, if it made one."""
|
|
1086
|
+
await self._t.aclose()
|
|
1087
|
+
|
|
1088
|
+
async def __aenter__(self) -> AsyncClient:
|
|
1089
|
+
return self
|
|
1090
|
+
|
|
1091
|
+
async def __aexit__(
|
|
1092
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
1093
|
+
) -> None:
|
|
1094
|
+
await self.aclose()
|
|
1095
|
+
|
|
1096
|
+
async def vm(self, name: str) -> AsyncVM:
|
|
1097
|
+
"""The VM with this name. NotFoundError if there is none. Requires ls permission."""
|
|
1098
|
+
return AsyncVM(self._t, await _find(self._t, vm_name(name)))
|
|
1099
|
+
|
|
1100
|
+
async def vms(self, pattern: str | None = None) -> list[AsyncVM]:
|
|
1101
|
+
"""Your VMs; with a pattern such as ``"web-*"``, those it matches. Requires ls permission."""
|
|
1102
|
+
result = await self._t.call("ls", [pattern], json=True)
|
|
1103
|
+
return [AsyncVM(self._t, info) for info in vm_list(result)]
|
|
1104
|
+
|
|
1105
|
+
async def delete(self, *vms: str | AsyncVM) -> t.RmResponse:
|
|
1106
|
+
"""Delete VMs in one request.
|
|
1107
|
+
|
|
1108
|
+
Unlike AsyncVM.delete(), VMs that were missing or could not be
|
|
1109
|
+
deleted do not raise: they are listed in failed, with reasons in
|
|
1110
|
+
errors. Requires rm permission.
|
|
1111
|
+
"""
|
|
1112
|
+
names = list(dict.fromkeys(vm.name if isinstance(vm, AsyncVM) else vm_name(vm) for vm in vms))
|
|
1113
|
+
if not names:
|
|
1114
|
+
return {"deleted": [], "failed": [], "not_found": [], "errors": {}}
|
|
1115
|
+
_, _, docs = await self._t.documents("rm", [names])
|
|
1116
|
+
return rm_result(docs)
|
|
1117
|
+
|
|
1118
|
+
async def doc(self, *queries: str) -> Any:
|
|
1119
|
+
"""Browse documentation.
|
|
1120
|
+
|
|
1121
|
+
CLI: ``doc [slug-or-query...]``
|
|
1122
|
+
|
|
1123
|
+
Tag-scoped API keys may run this command.
|
|
1124
|
+
"""
|
|
1125
|
+
return await self._t.call("doc", [queries])
|
|
1126
|
+
|
|
1127
|
+
@creates
|
|
1128
|
+
async def new(
|
|
1129
|
+
self,
|
|
1130
|
+
*,
|
|
1131
|
+
comment: str | None = None,
|
|
1132
|
+
cpu: int | None = None,
|
|
1133
|
+
disk: int | str | None = None,
|
|
1134
|
+
env: Mapping[str, str] | None = None,
|
|
1135
|
+
image: str | None = None,
|
|
1136
|
+
integrations: Sequence[str] | None = None,
|
|
1137
|
+
memory: int | str | None = None,
|
|
1138
|
+
name: str | None = None,
|
|
1139
|
+
no_pool: bool = False,
|
|
1140
|
+
pool: str | None = None,
|
|
1141
|
+
prompt: str | None = None,
|
|
1142
|
+
registry_auth: str | None = None,
|
|
1143
|
+
setup_script: str | None = None,
|
|
1144
|
+
standalone: bool = False,
|
|
1145
|
+
tags: Sequence[str] | None = None,
|
|
1146
|
+
email: bool = False,
|
|
1147
|
+
wait_until: WaitUntil = "ssh",
|
|
1148
|
+
timeout: float | None = None,
|
|
1149
|
+
keep_on_failure: bool = False,
|
|
1150
|
+
) -> AsyncVM:
|
|
1151
|
+
"""Create a VM. By default, it returns once the VM runs commands.
|
|
1152
|
+
|
|
1153
|
+
Tag-scoped API keys may run this command.
|
|
1154
|
+
|
|
1155
|
+
Args:
|
|
1156
|
+
comment: short note about the VM (max 200 bytes)
|
|
1157
|
+
cpu: number of CPUs (default 2; run 'billing plan' for plan limits)
|
|
1158
|
+
disk: disk size (e.g., 20, 20GB, 50G)
|
|
1159
|
+
env: environment variable in KEY=VALUE format (can be specified multiple
|
|
1160
|
+
times)
|
|
1161
|
+
image: container image (server default: exeuntu)
|
|
1162
|
+
integrations: integration name to attach
|
|
1163
|
+
memory: memory allocation (e.g., 4, 4GB, 8G)
|
|
1164
|
+
name: VM name (auto-generated if not specified)
|
|
1165
|
+
no_pool: use ordinary placement (not available on plans that place new VMs
|
|
1166
|
+
in pools)
|
|
1167
|
+
pool: create the VM in one of your team's pools (see ``exe.pool.list()``)
|
|
1168
|
+
prompt: initial prompt to send to Shelley after VM creation (requires an
|
|
1169
|
+
image with Shelley, like exeuntu)
|
|
1170
|
+
registry_auth: private container registry credentials as USERNAME:PASSWORD
|
|
1171
|
+
(e.g. octocat:ghp_xxx) for the registry hosting ``image``
|
|
1172
|
+
setup_script: setup script to run on first boot (max 10KiB)
|
|
1173
|
+
standalone: create a standalone VM with its own reserved capacity (requires
|
|
1174
|
+
a plan with standalone VMs and team approval)
|
|
1175
|
+
tags: tag to add to the VM
|
|
1176
|
+
email: email you about the VM, as the command line does. By default, it does
|
|
1177
|
+
not.
|
|
1178
|
+
wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
|
|
1179
|
+
also waits for its setup script to finish, and succeed; "created"
|
|
1180
|
+
returns at once. Waiting needs ssh permission, and raises VMReadyError
|
|
1181
|
+
if the VM does not become ready.
|
|
1182
|
+
timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
|
|
1183
|
+
keep_on_failure: keep a VM that does not become ready, for debugging:
|
|
1184
|
+
VMReadyError.vm is it. By default, it is deleted, as it is when the wait
|
|
1185
|
+
is interrupted.
|
|
1186
|
+
"""
|
|
1187
|
+
check_ready(wait_until, timeout)
|
|
1188
|
+
result = await self._t.call(
|
|
1189
|
+
"new",
|
|
1190
|
+
[],
|
|
1191
|
+
[
|
|
1192
|
+
("comment", "string", comment),
|
|
1193
|
+
("cpu", "number", cpu),
|
|
1194
|
+
("disk", "string", disk),
|
|
1195
|
+
("env", "repeated", env_flags(env)),
|
|
1196
|
+
("image", "string", image),
|
|
1197
|
+
("integration", "repeated", integrations),
|
|
1198
|
+
("memory", "string", memory),
|
|
1199
|
+
("name", "string", name),
|
|
1200
|
+
("no-pool", "bool", no_pool or None),
|
|
1201
|
+
("pool", "string", pool),
|
|
1202
|
+
("prompt", "string", prompt),
|
|
1203
|
+
("registry-auth", "string", registry_auth),
|
|
1204
|
+
("setup-script", "string", setup_script.replace("\\", "\\\\") if setup_script is not None else None),
|
|
1205
|
+
("standalone", "bool", standalone or None),
|
|
1206
|
+
("tag", "repeated", tags),
|
|
1207
|
+
("no-email", "bool", None if email else True),
|
|
1208
|
+
],
|
|
1209
|
+
json=True,
|
|
1210
|
+
)
|
|
1211
|
+
vm = AsyncVM(self._t, vm_info(result, "new"), owned=True)
|
|
1212
|
+
return await vm._created(wait_until, timeout, keep_on_failure)
|
|
1213
|
+
|
|
1214
|
+
async def whoami(self) -> t.WhoamiResponse:
|
|
1215
|
+
"""Show user information (email, keys, etc).
|
|
1216
|
+
|
|
1217
|
+
CLI: ``whoami``
|
|
1218
|
+
|
|
1219
|
+
Tag-scoped API keys may run this command.
|
|
1220
|
+
"""
|
|
1221
|
+
result: t.WhoamiResponse = await self._t.call("whoami", [], json=True)
|
|
1222
|
+
return result
|
|
1223
|
+
|
|
1224
|
+
async def set_region(self, region_code: str) -> Any:
|
|
1225
|
+
"""Set your preferred region for new VMs.
|
|
1226
|
+
|
|
1227
|
+
CLI: ``set-region <region-code>``
|
|
1228
|
+
"""
|
|
1229
|
+
return await self._t.call("set-region", [region_code])
|
|
1230
|
+
|
|
1231
|
+
async def browser(self, *, qr: bool = False) -> Any:
|
|
1232
|
+
"""Generate a magic link to log in to the website.
|
|
1233
|
+
|
|
1234
|
+
CLI: ``browser``
|
|
1235
|
+
|
|
1236
|
+
Args:
|
|
1237
|
+
qr: show QR code for the URL
|
|
1238
|
+
"""
|
|
1239
|
+
return await self._t.call("browser", [], [("qr", "bool", qr or None)])
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
class _AsyncDomain:
|
|
1243
|
+
"""``domain`` commands."""
|
|
1244
|
+
|
|
1245
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1246
|
+
self._t = t
|
|
1247
|
+
|
|
1248
|
+
async def ls(self, *args: str, all: bool = False) -> Any:
|
|
1249
|
+
"""List custom domains for a VM (or -a for all your VMs).
|
|
1250
|
+
|
|
1251
|
+
CLI: ``domain ls <vm> | domain ls -a``
|
|
1252
|
+
|
|
1253
|
+
Args:
|
|
1254
|
+
all: list domains across all your VMs
|
|
1255
|
+
"""
|
|
1256
|
+
return await self._t.call("domain ls", [args], [("a", "bool", all or None)])
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
class _AsyncSshKey:
|
|
1260
|
+
"""``ssh-key`` commands."""
|
|
1261
|
+
|
|
1262
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1263
|
+
self._t = t
|
|
1264
|
+
|
|
1265
|
+
async def list(self) -> Any:
|
|
1266
|
+
"""List all SSH keys associated with your account.
|
|
1267
|
+
|
|
1268
|
+
CLI: ``ssh-key list``
|
|
1269
|
+
|
|
1270
|
+
Tag-scoped API keys may run this command.
|
|
1271
|
+
"""
|
|
1272
|
+
return await self._t.call("ssh-key list", [])
|
|
1273
|
+
|
|
1274
|
+
async def add(self, public_key: str, *, tag: str | None = None) -> Any:
|
|
1275
|
+
"""Add a new SSH key to your account.
|
|
1276
|
+
|
|
1277
|
+
CLI: ``ssh-key add [--tag=TAG] <public-key>``
|
|
1278
|
+
|
|
1279
|
+
Tag-scoped API keys may run this command.
|
|
1280
|
+
|
|
1281
|
+
Args:
|
|
1282
|
+
tag: scope key to VMs with this tag
|
|
1283
|
+
"""
|
|
1284
|
+
return await self._t.call("ssh-key add", [public_key], [("tag", "string", tag)])
|
|
1285
|
+
|
|
1286
|
+
async def remove(self, key: str) -> Any:
|
|
1287
|
+
"""Remove an SSH key from your account.
|
|
1288
|
+
|
|
1289
|
+
CLI: ``ssh-key remove <name|fingerprint|public-key>``
|
|
1290
|
+
|
|
1291
|
+
Tag-scoped API keys may run this command.
|
|
1292
|
+
"""
|
|
1293
|
+
return await self._t.call("ssh-key remove", [key])
|
|
1294
|
+
|
|
1295
|
+
async def rename(self, old_name: str, new_name: str) -> Any:
|
|
1296
|
+
"""Rename an SSH key.
|
|
1297
|
+
|
|
1298
|
+
CLI: ``ssh-key rename <old-name> <new-name>``
|
|
1299
|
+
|
|
1300
|
+
Tag-scoped API keys may run this command.
|
|
1301
|
+
"""
|
|
1302
|
+
return await self._t.call("ssh-key rename", [old_name, new_name])
|
|
1303
|
+
|
|
1304
|
+
async def generate_api_key(
|
|
1305
|
+
self,
|
|
1306
|
+
*,
|
|
1307
|
+
cmds: str | None = None,
|
|
1308
|
+
exp: str | None = None,
|
|
1309
|
+
label: str | None = None,
|
|
1310
|
+
vm: str | None = None,
|
|
1311
|
+
) -> Any:
|
|
1312
|
+
"""Generate an API key for the exe.dev HTTPS API or for a specific VM.
|
|
1313
|
+
|
|
1314
|
+
CLI: ``ssh-key generate-api-key [--label=NAME] [--vm=VMNAME] [--cmds=CMD1,CMD2] [--exp=30d]``
|
|
1315
|
+
|
|
1316
|
+
Tag-scoped API keys may run this command.
|
|
1317
|
+
|
|
1318
|
+
Args:
|
|
1319
|
+
cmds: comma-separated list of allowed commands (empty = defaults)
|
|
1320
|
+
exp: expiry duration (e.g. 30d, 1y) or 'never'
|
|
1321
|
+
label: label for this token's SSH key
|
|
1322
|
+
vm: scope key to a VM (authenticates to its HTTPS endpoints instead of
|
|
1323
|
+
exe.dev commands)
|
|
1324
|
+
"""
|
|
1325
|
+
return await self._t.call(
|
|
1326
|
+
"ssh-key generate-api-key",
|
|
1327
|
+
[],
|
|
1328
|
+
[
|
|
1329
|
+
("cmds", "string", cmds),
|
|
1330
|
+
("exp", "string", exp),
|
|
1331
|
+
("label", "string", label),
|
|
1332
|
+
("vm", "string", vm),
|
|
1333
|
+
],
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
class _AsyncIntegrations:
|
|
1338
|
+
"""``integrations`` commands."""
|
|
1339
|
+
|
|
1340
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1341
|
+
self._t = t
|
|
1342
|
+
self.setup = _AsyncIntegrationsSetup(self._t)
|
|
1343
|
+
|
|
1344
|
+
async def list(self, *, usage: bool = False) -> Any:
|
|
1345
|
+
"""List your integrations.
|
|
1346
|
+
|
|
1347
|
+
CLI: ``integrations list [--json [--usage]]``
|
|
1348
|
+
|
|
1349
|
+
Tag-scoped API keys may run this command.
|
|
1350
|
+
|
|
1351
|
+
Args:
|
|
1352
|
+
usage: include per-VM usage (lastUsedAt, usedByVMs); JSON only
|
|
1353
|
+
"""
|
|
1354
|
+
return await self._t.call("integrations list", [], [("usage", "bool", usage or None)])
|
|
1355
|
+
|
|
1356
|
+
async def add(
|
|
1357
|
+
self,
|
|
1358
|
+
type: str,
|
|
1359
|
+
*,
|
|
1360
|
+
act_as_user: bool = False,
|
|
1361
|
+
attach: Sequence[str] | None = None,
|
|
1362
|
+
bearer: str | None = None,
|
|
1363
|
+
comment: str | None = None,
|
|
1364
|
+
default: bool = False,
|
|
1365
|
+
fields: str | None = None,
|
|
1366
|
+
for_: str | None = None,
|
|
1367
|
+
headers: Sequence[str] | None = None,
|
|
1368
|
+
host_keys: Sequence[str] | None = None,
|
|
1369
|
+
name: str | None = None,
|
|
1370
|
+
no_auth: bool = False,
|
|
1371
|
+
peer: bool = False,
|
|
1372
|
+
readonly: bool = False,
|
|
1373
|
+
repository: str | None = None,
|
|
1374
|
+
strip_prefix: str | None = None,
|
|
1375
|
+
target: str | None = None,
|
|
1376
|
+
team: bool = False,
|
|
1377
|
+
) -> Any:
|
|
1378
|
+
"""Add a new integration.
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
CLI: ``integrations add <type> [--default | --name=<name> [--team] [args...]]``
|
|
1383
|
+
|
|
1384
|
+
Args:
|
|
1385
|
+
act_as_user: authenticate as your GitHub user instead of the exe.dev app
|
|
1386
|
+
(github only); pushes and API calls are attributed to your GitHub
|
|
1387
|
+
account
|
|
1388
|
+
attach: attach to a spec (vm:<name>, tag:<name>, or auto:all)
|
|
1389
|
+
bearer: bearer token (shorthand for ``headers=["Authorization:Bearer
|
|
1390
|
+
TOKEN"]``; combines with ``headers``)
|
|
1391
|
+
comment: optional free-form comment stored with the integration
|
|
1392
|
+
default: install the canonical reflection integration with all fields;
|
|
1393
|
+
attaches to auto:all unless ``attach=["vm:<name>"]`` is supplied; no
|
|
1394
|
+
other customization
|
|
1395
|
+
fields: comma-separated reflection fields to expose (email, integrations,
|
|
1396
|
+
tags, comment, default_port); 'all' exposes every field including ones
|
|
1397
|
+
added in the future; 'none' disables every field (server default: all)
|
|
1398
|
+
for_: time-box every ``attach`` to a duration from now (e.g. 2h, 45m);
|
|
1399
|
+
access lapses automatically
|
|
1400
|
+
headers: header to inject (e.g. X-Auth:secret)
|
|
1401
|
+
host_keys: host keys for ssh-key: authorized_keys or known_hosts lines
|
|
1402
|
+
(hostnames are ignored), or '*' for any
|
|
1403
|
+
name: integration name (required unless ``default=True``)
|
|
1404
|
+
no_auth: create an endpoint integration with no injected authentication
|
|
1405
|
+
peer: authenticate with a generated API key scoped to the target VM;
|
|
1406
|
+
combines with ``headers``/``bearer``
|
|
1407
|
+
readonly: restrict the integration to read access (github only); git push
|
|
1408
|
+
and write API calls are rejected
|
|
1409
|
+
repository: GitHub repository in owner/repo format (required for github)
|
|
1410
|
+
strip_prefix: path prefix to strip from each request before forwarding
|
|
1411
|
+
(http-proxy only; e.g. /api/v3 makes /api/v3/users reach the target as
|
|
1412
|
+
/users)
|
|
1413
|
+
target: target URL (required for http-proxy)
|
|
1414
|
+
team: create as a team integration
|
|
1415
|
+
"""
|
|
1416
|
+
return await self._t.call(
|
|
1417
|
+
"integrations add",
|
|
1418
|
+
[type],
|
|
1419
|
+
[
|
|
1420
|
+
("act-as-user", "bool", act_as_user or None),
|
|
1421
|
+
("attach", "repeated", attach),
|
|
1422
|
+
("bearer", "string", bearer),
|
|
1423
|
+
("comment", "string", comment),
|
|
1424
|
+
("default", "bool", default or None),
|
|
1425
|
+
("fields", "string", fields),
|
|
1426
|
+
("for", "string", for_),
|
|
1427
|
+
("header", "repeated", headers),
|
|
1428
|
+
("host-key", "repeated", host_keys),
|
|
1429
|
+
("name", "string", name),
|
|
1430
|
+
("no-auth", "bool", no_auth or None),
|
|
1431
|
+
("peer", "bool", peer or None),
|
|
1432
|
+
("readonly", "bool", readonly or None),
|
|
1433
|
+
("repository", "string", repository),
|
|
1434
|
+
("strip-prefix", "string", strip_prefix),
|
|
1435
|
+
("target", "string", target),
|
|
1436
|
+
("team", "bool", team or None),
|
|
1437
|
+
],
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
async def remove(self, name: str, *, team: bool = False) -> Any:
|
|
1441
|
+
"""Remove an integration.
|
|
1442
|
+
|
|
1443
|
+
CLI: ``integrations remove <name> [--team]``
|
|
1444
|
+
|
|
1445
|
+
Args:
|
|
1446
|
+
team: operate on a team integration
|
|
1447
|
+
"""
|
|
1448
|
+
return await self._t.call("integrations remove", [name], [("team", "bool", team or None)])
|
|
1449
|
+
|
|
1450
|
+
async def test(self, name: str, *, team: bool = False) -> Any:
|
|
1451
|
+
"""Test an integration's credential (connection check).
|
|
1452
|
+
|
|
1453
|
+
CLI: ``integrations test <name> [--team]``
|
|
1454
|
+
|
|
1455
|
+
Args:
|
|
1456
|
+
team: operate on a team integration
|
|
1457
|
+
"""
|
|
1458
|
+
return await self._t.call("integrations test", [name], [("team", "bool", team or None)])
|
|
1459
|
+
|
|
1460
|
+
async def edit(
|
|
1461
|
+
self,
|
|
1462
|
+
name: str,
|
|
1463
|
+
*args: str,
|
|
1464
|
+
act_as_user: bool = False,
|
|
1465
|
+
bearer: str | None = None,
|
|
1466
|
+
clear_header: bool = False,
|
|
1467
|
+
comment: str | None = None,
|
|
1468
|
+
fields: str | None = None,
|
|
1469
|
+
headers: Sequence[str] | None = None,
|
|
1470
|
+
host_keys: Sequence[str] | None = None,
|
|
1471
|
+
no_auth: bool = False,
|
|
1472
|
+
readonly: bool = False,
|
|
1473
|
+
repository: str | None = None,
|
|
1474
|
+
strip_prefix: str | None = None,
|
|
1475
|
+
target: str | None = None,
|
|
1476
|
+
team: bool = False,
|
|
1477
|
+
webhook_url: str | None = None,
|
|
1478
|
+
) -> Any:
|
|
1479
|
+
"""Edit an integration.
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
CLI: ``integrations edit <name> [--team] [args...]``
|
|
1484
|
+
|
|
1485
|
+
Args:
|
|
1486
|
+
act_as_user: authenticate as your GitHub user instead of the exe.dev app
|
|
1487
|
+
(github only)
|
|
1488
|
+
bearer: bearer token (shorthand for ``headers=["Authorization:Bearer
|
|
1489
|
+
TOKEN"]``; combines with ``headers``)
|
|
1490
|
+
clear_header: remove injected headers for http-proxy
|
|
1491
|
+
comment: free-form comment stored with the integration
|
|
1492
|
+
fields: comma-separated reflection fields to expose (email, integrations,
|
|
1493
|
+
tags, comment, default_port); 'all' exposes every field including ones
|
|
1494
|
+
added in the future; 'none' disables every field
|
|
1495
|
+
headers: header to inject for http-proxy; replaces all existing headers
|
|
1496
|
+
host_keys: replacement host keys for ssh-key; replaces the whole list; one -
|
|
1497
|
+
reads them piped in
|
|
1498
|
+
no_auth: remove injected authentication for http-proxy
|
|
1499
|
+
readonly: restrict the integration to read access (github only)
|
|
1500
|
+
repository: GitHub repository in owner/repo format
|
|
1501
|
+
strip_prefix: path prefix to strip from each request before forwarding
|
|
1502
|
+
(http-proxy only; e.g. /api/v3; empty clears it)
|
|
1503
|
+
target: target URL for http-proxy
|
|
1504
|
+
team: edit a team integration
|
|
1505
|
+
webhook_url: replacement Slack or Discord webhook URL
|
|
1506
|
+
"""
|
|
1507
|
+
return await self._t.call(
|
|
1508
|
+
"integrations edit",
|
|
1509
|
+
[name, args],
|
|
1510
|
+
[
|
|
1511
|
+
("act-as-user", "bool", act_as_user or None),
|
|
1512
|
+
("bearer", "string", bearer),
|
|
1513
|
+
("clear-header", "bool", clear_header or None),
|
|
1514
|
+
("comment", "string", comment),
|
|
1515
|
+
("fields", "string", fields),
|
|
1516
|
+
("header", "repeated", headers),
|
|
1517
|
+
("host-key", "repeated", host_keys),
|
|
1518
|
+
("no-auth", "bool", no_auth or None),
|
|
1519
|
+
("readonly", "bool", readonly or None),
|
|
1520
|
+
("repository", "string", repository),
|
|
1521
|
+
("strip-prefix", "string", strip_prefix),
|
|
1522
|
+
("target", "string", target),
|
|
1523
|
+
("team", "bool", team or None),
|
|
1524
|
+
("webhook-url", "string", webhook_url),
|
|
1525
|
+
],
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
async def attach(
|
|
1529
|
+
self,
|
|
1530
|
+
name: str,
|
|
1531
|
+
spec: str,
|
|
1532
|
+
*,
|
|
1533
|
+
for_: str | None = None,
|
|
1534
|
+
team: bool = False,
|
|
1535
|
+
until: str | None = None,
|
|
1536
|
+
) -> Any:
|
|
1537
|
+
"""Attach an integration to a VM, tag, or all VMs.
|
|
1538
|
+
|
|
1539
|
+
A <spec> controls where the integration is mounted:
|
|
1540
|
+
vm:<vm-name> attach to a specific VM (personal only)
|
|
1541
|
+
tag:<tag-name> attach to every VM with the given tag
|
|
1542
|
+
auto:all attach to all current and future VMs (personal only)
|
|
1543
|
+
|
|
1544
|
+
You can attach the same integration multiple times with different specs.
|
|
1545
|
+
Team integrations only support tag:<tag-name>.
|
|
1546
|
+
|
|
1547
|
+
A grant can be time-boxed with ``for_=<duration>`` or ``until=<RFC3339>``;
|
|
1548
|
+
when it lapses, access stops immediately, with nothing to revoke.
|
|
1549
|
+
Re-attaching the same spec with a new ``for_``/``until`` extends or shortens it.
|
|
1550
|
+
|
|
1551
|
+
CLI: ``integrations attach <name> <spec> [--team] [--for <duration> | --until <time>]``
|
|
1552
|
+
|
|
1553
|
+
Args:
|
|
1554
|
+
for_: time-box the attachment to a duration from now (e.g. 2h, 45m); access
|
|
1555
|
+
lapses automatically
|
|
1556
|
+
team: operate on a team integration
|
|
1557
|
+
until: time-box the attachment until an RFC3339 instant (e.g.
|
|
1558
|
+
2026-08-03T20:00:00Z)
|
|
1559
|
+
"""
|
|
1560
|
+
return await self._t.call(
|
|
1561
|
+
"integrations attach",
|
|
1562
|
+
[name, spec],
|
|
1563
|
+
[
|
|
1564
|
+
("for", "string", for_),
|
|
1565
|
+
("team", "bool", team or None),
|
|
1566
|
+
("until", "string", until),
|
|
1567
|
+
],
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
async def detach(self, name: str, spec: str, *, team: bool = False) -> Any:
|
|
1571
|
+
"""Detach an integration from a VM, tag, or all VMs.
|
|
1572
|
+
|
|
1573
|
+
CLI: ``integrations detach <name> <spec> [--team]``
|
|
1574
|
+
|
|
1575
|
+
Args:
|
|
1576
|
+
team: operate on a team integration
|
|
1577
|
+
"""
|
|
1578
|
+
return await self._t.call("integrations detach", [name, spec], [("team", "bool", team or None)])
|
|
1579
|
+
|
|
1580
|
+
async def rename(self, name: str, new_name: str, *, team: bool = False) -> Any:
|
|
1581
|
+
"""Rename an integration.
|
|
1582
|
+
|
|
1583
|
+
CLI: ``integrations rename <name> <new-name> [--team]``
|
|
1584
|
+
|
|
1585
|
+
Args:
|
|
1586
|
+
team: operate on a team integration
|
|
1587
|
+
"""
|
|
1588
|
+
return await self._t.call(
|
|
1589
|
+
"integrations rename",
|
|
1590
|
+
[name, new_name],
|
|
1591
|
+
[("team", "bool", team or None)],
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
async def catalog(self, query: str | None = None) -> Any:
|
|
1595
|
+
"""Browse the catalog of ready-made service integrations.
|
|
1596
|
+
|
|
1597
|
+
CLI: ``integrations catalog [service-or-search-term]``
|
|
1598
|
+
"""
|
|
1599
|
+
return await self._t.call("integrations catalog", [query])
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
class _AsyncIntegrationsSetup:
|
|
1603
|
+
"""``integrations setup`` commands."""
|
|
1604
|
+
|
|
1605
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1606
|
+
self._t = t
|
|
1607
|
+
|
|
1608
|
+
async def github(self, *, delete: bool = False, list: bool = False, verify: bool = False) -> Any:
|
|
1609
|
+
"""Set up GitHub integration.
|
|
1610
|
+
|
|
1611
|
+
CLI: ``integrations setup github [--list|--verify|-d]``
|
|
1612
|
+
|
|
1613
|
+
Args:
|
|
1614
|
+
delete: disconnect GitHub account
|
|
1615
|
+
list: list connected GitHub accounts
|
|
1616
|
+
verify: verify GitHub connections are working
|
|
1617
|
+
"""
|
|
1618
|
+
return await self._t.call(
|
|
1619
|
+
"integrations setup github",
|
|
1620
|
+
[],
|
|
1621
|
+
[
|
|
1622
|
+
("delete", "bool", delete or None),
|
|
1623
|
+
("list", "bool", list or None),
|
|
1624
|
+
("verify", "bool", verify or None),
|
|
1625
|
+
],
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
async def chatgpt(
|
|
1629
|
+
self,
|
|
1630
|
+
*,
|
|
1631
|
+
delete: bool = False,
|
|
1632
|
+
list: bool = False,
|
|
1633
|
+
name: str | None = None,
|
|
1634
|
+
verify: bool = False,
|
|
1635
|
+
) -> Any:
|
|
1636
|
+
"""Set up ChatGPT account access for LLM integrations.
|
|
1637
|
+
|
|
1638
|
+
CLI: ``integrations setup chatgpt [--name=<account-name>|--list|--verify|-d]``
|
|
1639
|
+
|
|
1640
|
+
Args:
|
|
1641
|
+
delete: disconnect ChatGPT account
|
|
1642
|
+
list: list connected ChatGPT accounts
|
|
1643
|
+
name: local ChatGPT account name (default chatgpt)
|
|
1644
|
+
verify: verify ChatGPT connections are working
|
|
1645
|
+
"""
|
|
1646
|
+
return await self._t.call(
|
|
1647
|
+
"integrations setup chatgpt",
|
|
1648
|
+
[],
|
|
1649
|
+
[
|
|
1650
|
+
("delete", "bool", delete or None),
|
|
1651
|
+
("list", "bool", list or None),
|
|
1652
|
+
("name", "string", name),
|
|
1653
|
+
("verify", "bool", verify or None),
|
|
1654
|
+
],
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
class _AsyncTeam:
|
|
1659
|
+
"""``team`` commands."""
|
|
1660
|
+
|
|
1661
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1662
|
+
self._t = t
|
|
1663
|
+
self.billing = _AsyncTeamBilling(self._t)
|
|
1664
|
+
self.auth = _AsyncTeamAuth(self._t)
|
|
1665
|
+
self.settings = _AsyncTeamSettings(self._t)
|
|
1666
|
+
self.vm = _AsyncTeamVm(self._t)
|
|
1667
|
+
|
|
1668
|
+
async def __call__(self) -> Any:
|
|
1669
|
+
"""View and manage your team."""
|
|
1670
|
+
return await self._t.call("team", [])
|
|
1671
|
+
|
|
1672
|
+
async def disable(self, *, yes: bool = False) -> Any:
|
|
1673
|
+
"""Disband your team.
|
|
1674
|
+
|
|
1675
|
+
CLI: ``team disable``
|
|
1676
|
+
|
|
1677
|
+
Args:
|
|
1678
|
+
yes: skip the confirmation prompt (required for non-interactive use, e.g.
|
|
1679
|
+
the web UI)
|
|
1680
|
+
"""
|
|
1681
|
+
return await self._t.call("team disable", [], [("yes", "bool", yes or None)])
|
|
1682
|
+
|
|
1683
|
+
async def members(self) -> Any:
|
|
1684
|
+
"""List team members.
|
|
1685
|
+
|
|
1686
|
+
CLI: ``team members``
|
|
1687
|
+
"""
|
|
1688
|
+
return await self._t.call("team members", [])
|
|
1689
|
+
|
|
1690
|
+
async def usage(self) -> Any:
|
|
1691
|
+
"""Show team pool, disk, and bandwidth usage for the billing period.
|
|
1692
|
+
|
|
1693
|
+
CLI: ``team usage``
|
|
1694
|
+
"""
|
|
1695
|
+
return await self._t.call("team usage", [])
|
|
1696
|
+
|
|
1697
|
+
async def add(self, email: str, role: Literal["user", "admin", "billing_owner"] | None = None) -> Any:
|
|
1698
|
+
"""Add a user to the team.
|
|
1699
|
+
|
|
1700
|
+
CLI: ``team add <email> [<user|admin|billing_owner>]``
|
|
1701
|
+
"""
|
|
1702
|
+
return await self._t.call("team add", [email, role])
|
|
1703
|
+
|
|
1704
|
+
async def remove(self, email: str, *, transfer_vms_to: str | None = None) -> Any:
|
|
1705
|
+
"""Remove a user from the team.
|
|
1706
|
+
|
|
1707
|
+
CLI: ``team remove <email> [--transfer-vms-to <email>]``
|
|
1708
|
+
|
|
1709
|
+
Args:
|
|
1710
|
+
transfer_vms_to: transfer the member's VMs to this team member before
|
|
1711
|
+
removing them
|
|
1712
|
+
"""
|
|
1713
|
+
return await self._t.call(
|
|
1714
|
+
"team remove",
|
|
1715
|
+
[email],
|
|
1716
|
+
[("transfer-vms-to", "string", transfer_vms_to)],
|
|
1717
|
+
)
|
|
1718
|
+
|
|
1719
|
+
async def role(self, email: str, role: Literal["user", "admin", "billing_owner"]) -> Any:
|
|
1720
|
+
"""Change a team member's role.
|
|
1721
|
+
|
|
1722
|
+
CLI: ``team role <email> <user|admin|billing_owner>``
|
|
1723
|
+
"""
|
|
1724
|
+
return await self._t.call("team role", [email, role])
|
|
1725
|
+
|
|
1726
|
+
async def rename(self, name: str) -> Any:
|
|
1727
|
+
"""Rename your team.
|
|
1728
|
+
|
|
1729
|
+
CLI: ``team rename <name>``
|
|
1730
|
+
"""
|
|
1731
|
+
return await self._t.call("team rename", [name])
|
|
1732
|
+
|
|
1733
|
+
async def transfer(self, vm_name: str, target_email: str) -> Any:
|
|
1734
|
+
"""Transfer a VM to another team member.
|
|
1735
|
+
|
|
1736
|
+
CLI: ``team transfer <vm_name> <target_email>``
|
|
1737
|
+
"""
|
|
1738
|
+
return await self._t.call("team transfer", [vm_name, target_email])
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
class _AsyncTeamBilling:
|
|
1742
|
+
"""``team billing`` commands."""
|
|
1743
|
+
|
|
1744
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1745
|
+
self._t = t
|
|
1746
|
+
|
|
1747
|
+
async def plan(self, *, all: bool = False) -> Any:
|
|
1748
|
+
"""Show your team's plan.
|
|
1749
|
+
|
|
1750
|
+
CLI: ``team billing plan [--all]``
|
|
1751
|
+
|
|
1752
|
+
Args:
|
|
1753
|
+
all: list the plans you can switch to
|
|
1754
|
+
"""
|
|
1755
|
+
return await self._t.call("team billing plan", [], [("all", "bool", all or None)])
|
|
1756
|
+
|
|
1757
|
+
async def update(
|
|
1758
|
+
self,
|
|
1759
|
+
*,
|
|
1760
|
+
address_city: str | None = None,
|
|
1761
|
+
address_country: str | None = None,
|
|
1762
|
+
address_line1: str | None = None,
|
|
1763
|
+
address_line2: str | None = None,
|
|
1764
|
+
address_postal_code: str | None = None,
|
|
1765
|
+
address_state: str | None = None,
|
|
1766
|
+
business_name: str | None = None,
|
|
1767
|
+
email: str | None = None,
|
|
1768
|
+
name: str | None = None,
|
|
1769
|
+
phone: str | None = None,
|
|
1770
|
+
tax_id_type: str | None = None,
|
|
1771
|
+
tax_id_value: str | None = None,
|
|
1772
|
+
) -> Any:
|
|
1773
|
+
"""Update team billing information.
|
|
1774
|
+
|
|
1775
|
+
CLI: ``team billing update [--email=<email>] [--name=<name>] [--business-name=<name>] [--phone=<phone>] [--address-line1=<line>] [--address-line2=<line>] [--address-city=<city>] [--address-state=<state>] [--address-postal-code=<code>] [--address-country=<country>] [--tax-id-type=<type>] [--tax-id-value=<value>]``
|
|
1776
|
+
|
|
1777
|
+
Args:
|
|
1778
|
+
address_city: billing address city
|
|
1779
|
+
address_country: billing address country code
|
|
1780
|
+
address_line1: billing address line 1
|
|
1781
|
+
address_line2: billing address line 2
|
|
1782
|
+
address_postal_code: billing address postal code
|
|
1783
|
+
address_state: billing address state or province
|
|
1784
|
+
business_name: business name shown on invoices
|
|
1785
|
+
email: billing email shown on invoices
|
|
1786
|
+
name: customer name shown on invoices
|
|
1787
|
+
phone: phone number shown on invoices
|
|
1788
|
+
tax_id_type: tax ID type shown on invoices (e.g. eu_vat, pl_nip, us_ein)
|
|
1789
|
+
tax_id_value: tax ID value shown on invoices
|
|
1790
|
+
"""
|
|
1791
|
+
return await self._t.call(
|
|
1792
|
+
"team billing update",
|
|
1793
|
+
[],
|
|
1794
|
+
[
|
|
1795
|
+
("address-city", "string", address_city),
|
|
1796
|
+
("address-country", "string", address_country),
|
|
1797
|
+
("address-line1", "string", address_line1),
|
|
1798
|
+
("address-line2", "string", address_line2),
|
|
1799
|
+
("address-postal-code", "string", address_postal_code),
|
|
1800
|
+
("address-state", "string", address_state),
|
|
1801
|
+
("business-name", "string", business_name),
|
|
1802
|
+
("email", "string", email),
|
|
1803
|
+
("name", "string", name),
|
|
1804
|
+
("phone", "string", phone),
|
|
1805
|
+
("tax-id-type", "string", tax_id_type),
|
|
1806
|
+
("tax-id-value", "string", tax_id_value),
|
|
1807
|
+
],
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
class _AsyncTeamAuth:
|
|
1812
|
+
"""``team auth`` commands."""
|
|
1813
|
+
|
|
1814
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1815
|
+
self._t = t
|
|
1816
|
+
|
|
1817
|
+
async def __call__(self) -> Any:
|
|
1818
|
+
"""View and manage team auth settings.
|
|
1819
|
+
|
|
1820
|
+
CLI: ``team auth``
|
|
1821
|
+
"""
|
|
1822
|
+
return await self._t.call("team auth", [])
|
|
1823
|
+
|
|
1824
|
+
async def require_oidc(self, mode: Literal["web", "off"]) -> Any:
|
|
1825
|
+
"""Require OIDC for web login (web|off).
|
|
1826
|
+
|
|
1827
|
+
CLI: ``team auth require-oidc <web|off>``
|
|
1828
|
+
"""
|
|
1829
|
+
return await self._t.call("team auth require-oidc", [mode])
|
|
1830
|
+
|
|
1831
|
+
async def set(
|
|
1832
|
+
self,
|
|
1833
|
+
provider: Literal["default", "google", "oidc"],
|
|
1834
|
+
*,
|
|
1835
|
+
client_id: str | None = None,
|
|
1836
|
+
client_secret: str | None = None,
|
|
1837
|
+
display_name: str | None = None,
|
|
1838
|
+
issuer_url: str | None = None,
|
|
1839
|
+
) -> Any:
|
|
1840
|
+
"""Set the team auth provider (default, google, oidc).
|
|
1841
|
+
|
|
1842
|
+
CLI: ``team auth set <default|google|oidc> [--issuer-url=<url> --client-id=<id> --client-secret=<secret>]``
|
|
1843
|
+
|
|
1844
|
+
Args:
|
|
1845
|
+
client_id: OIDC client ID
|
|
1846
|
+
client_secret: OIDC client secret
|
|
1847
|
+
display_name: display name for the SSO provider
|
|
1848
|
+
issuer_url: OIDC issuer URL (e.g. https://accounts.google.com)
|
|
1849
|
+
"""
|
|
1850
|
+
return await self._t.call(
|
|
1851
|
+
"team auth set",
|
|
1852
|
+
[provider],
|
|
1853
|
+
[
|
|
1854
|
+
("client-id", "string", client_id),
|
|
1855
|
+
("client-secret", "string", client_secret),
|
|
1856
|
+
("display-name", "string", display_name),
|
|
1857
|
+
("issuer-url", "string", issuer_url),
|
|
1858
|
+
],
|
|
1859
|
+
)
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
class _AsyncTeamSettings:
|
|
1863
|
+
"""``team settings`` commands."""
|
|
1864
|
+
|
|
1865
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1866
|
+
self._t = t
|
|
1867
|
+
self.vm_placement = _AsyncTeamSettingsVmPlacement(self._t)
|
|
1868
|
+
|
|
1869
|
+
async def __call__(self) -> Any:
|
|
1870
|
+
"""View and manage team settings.
|
|
1871
|
+
|
|
1872
|
+
CLI: ``team settings``
|
|
1873
|
+
"""
|
|
1874
|
+
return await self._t.call("team settings", [])
|
|
1875
|
+
|
|
1876
|
+
async def standalone(self, who: Literal["off", "admins-only", "all-users"]) -> Any:
|
|
1877
|
+
"""Set who can create standalone VMs.
|
|
1878
|
+
|
|
1879
|
+
CLI: ``team settings standalone <off|admins-only|all-users>``
|
|
1880
|
+
"""
|
|
1881
|
+
return await self._t.call("team settings standalone", [who])
|
|
1882
|
+
|
|
1883
|
+
async def llm_gateway(self, enabled: bool) -> Any:
|
|
1884
|
+
"""Allow or block the exe.dev LLM gateway (your own API keys are unaffected).
|
|
1885
|
+
|
|
1886
|
+
CLI: ``team settings llm-gateway <on|off>``
|
|
1887
|
+
"""
|
|
1888
|
+
return await self._t.call("team settings llm-gateway", [on_off(enabled)])
|
|
1889
|
+
|
|
1890
|
+
async def core_sharing(self, enabled: bool) -> Any:
|
|
1891
|
+
"""Allow team VMs to share CPU cores with each other.
|
|
1892
|
+
|
|
1893
|
+
CLI: ``team settings core-sharing <on|off>``
|
|
1894
|
+
"""
|
|
1895
|
+
return await self._t.call("team settings core-sharing", [on_off(enabled)])
|
|
1896
|
+
|
|
1897
|
+
async def vm_sharing(self, who: Literal["admins-only", "all-members"]) -> Any:
|
|
1898
|
+
"""Set who can share team VMs.
|
|
1899
|
+
|
|
1900
|
+
CLI: ``team settings vm-sharing <admins-only|all-members>``
|
|
1901
|
+
"""
|
|
1902
|
+
return await self._t.call("team settings vm-sharing", [who])
|
|
1903
|
+
|
|
1904
|
+
async def session_length(self, length: str) -> Any:
|
|
1905
|
+
"""Set how long members stay signed in on the web and mobile apps.
|
|
1906
|
+
|
|
1907
|
+
CLI: ``team settings session-length <length>``
|
|
1908
|
+
"""
|
|
1909
|
+
return await self._t.call("team settings session-length", [length])
|
|
1910
|
+
|
|
1911
|
+
async def auto_join(self, enabled: bool) -> Any:
|
|
1912
|
+
"""Allow users from your email domain to join this team on signup.
|
|
1913
|
+
|
|
1914
|
+
CLI: ``team settings auto-join <on|off>``
|
|
1915
|
+
"""
|
|
1916
|
+
return await self._t.call("team settings auto-join", [on_off(enabled)])
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
class _AsyncTeamSettingsVmPlacement:
|
|
1920
|
+
"""``team settings vm-placement`` commands."""
|
|
1921
|
+
|
|
1922
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1923
|
+
self._t = t
|
|
1924
|
+
|
|
1925
|
+
async def __call__(self) -> Any:
|
|
1926
|
+
"""View and manage automatic VM placement.
|
|
1927
|
+
|
|
1928
|
+
CLI: ``team settings vm-placement``
|
|
1929
|
+
"""
|
|
1930
|
+
return await self._t.call("team settings vm-placement", [])
|
|
1931
|
+
|
|
1932
|
+
async def default(self) -> Any:
|
|
1933
|
+
"""Use ordinary VM placement.
|
|
1934
|
+
|
|
1935
|
+
CLI: ``team settings vm-placement default``
|
|
1936
|
+
"""
|
|
1937
|
+
return await self._t.call("team settings vm-placement default", [])
|
|
1938
|
+
|
|
1939
|
+
async def pool(self, name: str) -> Any:
|
|
1940
|
+
"""Send bare new commands to a shared team pool.
|
|
1941
|
+
|
|
1942
|
+
CLI: ``team settings vm-placement pool <name>``
|
|
1943
|
+
"""
|
|
1944
|
+
return await self._t.call("team settings vm-placement pool", [name])
|
|
1945
|
+
|
|
1946
|
+
async def member_pool(self, *, cpus: int | None = None, max_vms: int | None = None) -> Any:
|
|
1947
|
+
"""Create or reuse one team pool per member for bare new commands.
|
|
1948
|
+
|
|
1949
|
+
CLI: ``team settings vm-placement member-pool --cpus=N [--max-vms=N]``
|
|
1950
|
+
|
|
1951
|
+
Args:
|
|
1952
|
+
cpus: CPUs to reserve for each member pool (required)
|
|
1953
|
+
max_vms: maximum VMs in each member pool (Work defaults to 25 per vCPU, up
|
|
1954
|
+
to 1000) (server default: 100)
|
|
1955
|
+
"""
|
|
1956
|
+
return await self._t.call(
|
|
1957
|
+
"team settings vm-placement member-pool",
|
|
1958
|
+
[],
|
|
1959
|
+
[("cpus", "number", cpus), ("max-vms", "number", max_vms)],
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
async def poolless(self) -> Any:
|
|
1963
|
+
"""Keep bare new commands outside pools while migrating (legacy).
|
|
1964
|
+
|
|
1965
|
+
CLI: ``team settings vm-placement poolless``
|
|
1966
|
+
"""
|
|
1967
|
+
return await self._t.call("team settings vm-placement poolless", [])
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
class _AsyncTeamVm:
|
|
1971
|
+
"""``team vm`` commands."""
|
|
1972
|
+
|
|
1973
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
1974
|
+
self._t = t
|
|
1975
|
+
|
|
1976
|
+
async def ls(
|
|
1977
|
+
self,
|
|
1978
|
+
value: str | None = None,
|
|
1979
|
+
*,
|
|
1980
|
+
group: Literal["none", "tag", "region", "type", "user", "access"] | None = None,
|
|
1981
|
+
long: bool = False,
|
|
1982
|
+
) -> Any:
|
|
1983
|
+
"""List all VMs across your team.
|
|
1984
|
+
|
|
1985
|
+
CLI: ``team vm ls [-l] [--group=tag|region|type|user|access] [name|pattern]``
|
|
1986
|
+
|
|
1987
|
+
Tag-scoped API keys may run this command.
|
|
1988
|
+
|
|
1989
|
+
Args:
|
|
1990
|
+
group: group VMs by none|tag|region|type|user|access (server default: none)
|
|
1991
|
+
long: show detailed information
|
|
1992
|
+
"""
|
|
1993
|
+
return await self._t.call(
|
|
1994
|
+
"team vm ls",
|
|
1995
|
+
[value],
|
|
1996
|
+
[("group", "string", group), ("l", "bool", long or None)],
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1999
|
+
|
|
2000
|
+
class _AsyncPool:
|
|
2001
|
+
"""``pool`` commands."""
|
|
2002
|
+
|
|
2003
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
2004
|
+
self._t = t
|
|
2005
|
+
|
|
2006
|
+
async def new(
|
|
2007
|
+
self,
|
|
2008
|
+
name: str,
|
|
2009
|
+
*,
|
|
2010
|
+
cpus: int | None = None,
|
|
2011
|
+
max_vms: int | None = None,
|
|
2012
|
+
region: str | None = None,
|
|
2013
|
+
) -> Any:
|
|
2014
|
+
"""Create a pool: reserved capacity for your team's VMs.
|
|
2015
|
+
|
|
2016
|
+
CLI: ``pool new <name> --cpus=N [--region=<region>] [--max-vms=M]``
|
|
2017
|
+
|
|
2018
|
+
Args:
|
|
2019
|
+
cpus: number of CPUs reserved for the pool (required; an even number of
|
|
2020
|
+
vCPUs from 2 through 512 (2, 4, 6, and so on; memory is 2 GiB per vCPU))
|
|
2021
|
+
max_vms: maximum VMs in the pool (Work defaults to the size limit of up to
|
|
2022
|
+
25 VMs per vCPU, capped at 1000 VMs) (server default: 100)
|
|
2023
|
+
region: region code for the pool (required unless your team has dedicated
|
|
2024
|
+
hosts, which decide the region)
|
|
2025
|
+
"""
|
|
2026
|
+
return await self._t.call(
|
|
2027
|
+
"pool new",
|
|
2028
|
+
[name],
|
|
2029
|
+
[
|
|
2030
|
+
("cpus", "number", cpus),
|
|
2031
|
+
("max-vms", "number", max_vms),
|
|
2032
|
+
("region", "string", region),
|
|
2033
|
+
],
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
async def hosts(self) -> Any:
|
|
2037
|
+
"""Show your team's dedicated hosts, where its new pools are placed.
|
|
2038
|
+
|
|
2039
|
+
CLI: ``pool hosts``
|
|
2040
|
+
"""
|
|
2041
|
+
return await self._t.call("pool hosts", [])
|
|
2042
|
+
|
|
2043
|
+
async def list(self, pool_name: str | None = None, *, range: str | None = None, usage: bool = False) -> Any:
|
|
2044
|
+
"""List your team's pools, or show live or historical usage with ``usage=True``.
|
|
2045
|
+
|
|
2046
|
+
CLI: ``pool list [pool-name] [--usage [--range=live|24h|7d|30d]]``
|
|
2047
|
+
|
|
2048
|
+
Args:
|
|
2049
|
+
range: statistics range: live, 24h, 7d, or 30d (requires ``usage=True``)
|
|
2050
|
+
(server default: live)
|
|
2051
|
+
usage: show live or historical CPU and starvation statistics
|
|
2052
|
+
"""
|
|
2053
|
+
return await self._t.call(
|
|
2054
|
+
"pool list",
|
|
2055
|
+
[pool_name],
|
|
2056
|
+
[("range", "string", range), ("usage", "bool", usage or None)],
|
|
2057
|
+
)
|
|
2058
|
+
|
|
2059
|
+
async def adopt(self, *, pool: str | None = None, vm: str | None = None) -> Any:
|
|
2060
|
+
"""Adopt a VM into a pool; this can take several minutes.
|
|
2061
|
+
|
|
2062
|
+
CLI: ``pool adopt --vm=<vm-name> --pool=<pool-name>``
|
|
2063
|
+
|
|
2064
|
+
Args:
|
|
2065
|
+
pool: pool to adopt into (required)
|
|
2066
|
+
vm: VM to adopt (required)
|
|
2067
|
+
"""
|
|
2068
|
+
return await self._t.call("pool adopt", [], [("pool", "string", pool), ("vm", "string", vm)])
|
|
2069
|
+
|
|
2070
|
+
async def detach(self, *, poolless: bool = False, vm: str | None = None) -> Any:
|
|
2071
|
+
"""Detach a VM from its pool into its own standalone capacity (``poolless=True`` leaves it outside any pool).
|
|
2072
|
+
|
|
2073
|
+
CLI: ``pool detach --vm=<vm-name> [--poolless]``
|
|
2074
|
+
|
|
2075
|
+
Args:
|
|
2076
|
+
poolless: leave the VM outside any pool instead of making it standalone
|
|
2077
|
+
(only while your team has poolless VMs)
|
|
2078
|
+
vm: VM to detach from its pool (required)
|
|
2079
|
+
"""
|
|
2080
|
+
return await self._t.call(
|
|
2081
|
+
"pool detach",
|
|
2082
|
+
[],
|
|
2083
|
+
[("poolless", "bool", poolless or None), ("vm", "string", vm)],
|
|
2084
|
+
)
|
|
2085
|
+
|
|
2086
|
+
async def resize(
|
|
2087
|
+
self,
|
|
2088
|
+
*args: str,
|
|
2089
|
+
cpus: int | None = None,
|
|
2090
|
+
force: bool = False,
|
|
2091
|
+
max_vms: str | None = None,
|
|
2092
|
+
) -> Any:
|
|
2093
|
+
"""Change a pool's CPU capacity or VM cap.
|
|
2094
|
+
|
|
2095
|
+
CLI: ``pool resize <name> --cpus=N | pool resize <name> --max-vms=N [--force]``
|
|
2096
|
+
|
|
2097
|
+
Args:
|
|
2098
|
+
cpus: new pool size in vCPUs (required unless ``max_vms`` is set; even, from
|
|
2099
|
+
2 up to the placement limit)
|
|
2100
|
+
force: allow ``max_vms`` below the current VM count (does not stop or evict
|
|
2101
|
+
VMs)
|
|
2102
|
+
max_vms: maximum VMs in the pool (Work: at most 25 per vCPU, up to 1000)
|
|
2103
|
+
"""
|
|
2104
|
+
return await self._t.call(
|
|
2105
|
+
"pool resize",
|
|
2106
|
+
[args],
|
|
2107
|
+
[
|
|
2108
|
+
("cpus", "number", cpus),
|
|
2109
|
+
("force", "bool", force or None),
|
|
2110
|
+
("max-vms", "string", max_vms),
|
|
2111
|
+
],
|
|
2112
|
+
)
|
|
2113
|
+
|
|
2114
|
+
async def rename(self, old_name: str, new_name: str) -> Any:
|
|
2115
|
+
"""Rename a pool without changing its capacity or VMs.
|
|
2116
|
+
|
|
2117
|
+
CLI: ``pool rename <old-name> <new-name>``
|
|
2118
|
+
"""
|
|
2119
|
+
return await self._t.call("pool rename", [old_name, new_name])
|
|
2120
|
+
|
|
2121
|
+
async def delete(self, name: str) -> Any:
|
|
2122
|
+
"""Delete a pool (refused while it has VMs; ``force`` detaches them).
|
|
2123
|
+
|
|
2124
|
+
CLI: ``pool delete <name> [--force]``
|
|
2125
|
+
"""
|
|
2126
|
+
return await self._t.call("pool delete", [name])
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
class _AsyncBilling:
|
|
2130
|
+
"""``billing`` commands."""
|
|
2131
|
+
|
|
2132
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
2133
|
+
self._t = t
|
|
2134
|
+
self.credits = _AsyncBillingCredits(self._t)
|
|
2135
|
+
self.payment = _AsyncBillingPayment(self._t)
|
|
2136
|
+
self.invoices = _AsyncBillingInvoices(self._t)
|
|
2137
|
+
self.provider = _AsyncBillingProvider(self._t)
|
|
2138
|
+
|
|
2139
|
+
async def plan(self, *, all: bool = False) -> Any:
|
|
2140
|
+
"""Show your current plan and resource limits.
|
|
2141
|
+
|
|
2142
|
+
CLI: ``billing plan [--all]``
|
|
2143
|
+
|
|
2144
|
+
Args:
|
|
2145
|
+
all: list the plans you can switch to
|
|
2146
|
+
"""
|
|
2147
|
+
return await self._t.call("billing plan", [], [("all", "bool", all or None)])
|
|
2148
|
+
|
|
2149
|
+
async def usage(self, *, group: str | None = None, range: str | None = None) -> Any:
|
|
2150
|
+
"""Show VM resource usage against your plan quotas.
|
|
2151
|
+
|
|
2152
|
+
CLI: ``billing usage [--range=cycle|24h|7d|30d] [--group=vm]``
|
|
2153
|
+
|
|
2154
|
+
Args:
|
|
2155
|
+
group: group usage by: vm
|
|
2156
|
+
range: time range: cycle (current billing cycle), 24h, 7d, or 30d (server
|
|
2157
|
+
default: cycle)
|
|
2158
|
+
"""
|
|
2159
|
+
return await self._t.call(
|
|
2160
|
+
"billing usage",
|
|
2161
|
+
[],
|
|
2162
|
+
[("group", "string", group), ("range", "string", range)],
|
|
2163
|
+
)
|
|
2164
|
+
|
|
2165
|
+
async def rewards(self) -> Any:
|
|
2166
|
+
"""Show invite rewards you've earned.
|
|
2167
|
+
|
|
2168
|
+
CLI: ``billing rewards``
|
|
2169
|
+
"""
|
|
2170
|
+
return await self._t.call("billing rewards", [])
|
|
2171
|
+
|
|
2172
|
+
async def capacity(self, *, cpu: int | None = None, yes: bool = False) -> Any:
|
|
2173
|
+
"""Change your subscription capacity.
|
|
2174
|
+
|
|
2175
|
+
CLI: ``billing capacity [--cpu=N] [--yes]``
|
|
2176
|
+
|
|
2177
|
+
Args:
|
|
2178
|
+
cpu: Personal capacity in vCPUs: 2, 4, 8, or 16
|
|
2179
|
+
yes: skip interactive confirmation (required for non-interactive changes)
|
|
2180
|
+
"""
|
|
2181
|
+
return await self._t.call(
|
|
2182
|
+
"billing capacity",
|
|
2183
|
+
[],
|
|
2184
|
+
[("cpu", "number", cpu), ("yes", "bool", yes or None)],
|
|
2185
|
+
)
|
|
2186
|
+
|
|
2187
|
+
async def manage(self) -> Any:
|
|
2188
|
+
"""Open the billing management page.
|
|
2189
|
+
|
|
2190
|
+
CLI: ``billing manage``
|
|
2191
|
+
"""
|
|
2192
|
+
return await self._t.call("billing manage", [])
|
|
2193
|
+
|
|
2194
|
+
async def activate(self) -> Any:
|
|
2195
|
+
"""Open the personal plan activation page.
|
|
2196
|
+
|
|
2197
|
+
CLI: ``billing activate``
|
|
2198
|
+
"""
|
|
2199
|
+
return await self._t.call("billing activate", [])
|
|
2200
|
+
|
|
2201
|
+
async def update(
|
|
2202
|
+
self,
|
|
2203
|
+
*,
|
|
2204
|
+
address_city: str | None = None,
|
|
2205
|
+
address_country: str | None = None,
|
|
2206
|
+
address_line1: str | None = None,
|
|
2207
|
+
address_line2: str | None = None,
|
|
2208
|
+
address_postal_code: str | None = None,
|
|
2209
|
+
address_state: str | None = None,
|
|
2210
|
+
business_name: str | None = None,
|
|
2211
|
+
email: str | None = None,
|
|
2212
|
+
name: str | None = None,
|
|
2213
|
+
phone: str | None = None,
|
|
2214
|
+
tax_id_type: str | None = None,
|
|
2215
|
+
tax_id_value: str | None = None,
|
|
2216
|
+
) -> Any:
|
|
2217
|
+
"""Update your billing contact information.
|
|
2218
|
+
|
|
2219
|
+
CLI: ``billing update [--email=<email>] [--name=<name>] [--business-name=<name>] [--phone=<phone>] [--address-line1=<line>] [--address-line2=<line>] [--address-city=<city>] [--address-state=<state>] [--address-postal-code=<code>] [--address-country=<country>] [--tax-id-type=<type>] [--tax-id-value=<value>]``
|
|
2220
|
+
|
|
2221
|
+
Args:
|
|
2222
|
+
address_city: billing address city
|
|
2223
|
+
address_country: billing address country code
|
|
2224
|
+
address_line1: billing address line 1
|
|
2225
|
+
address_line2: billing address line 2
|
|
2226
|
+
address_postal_code: billing address postal code
|
|
2227
|
+
address_state: billing address state or province
|
|
2228
|
+
business_name: business name shown on invoices
|
|
2229
|
+
email: billing email shown on invoices
|
|
2230
|
+
name: customer name shown on invoices
|
|
2231
|
+
phone: phone number shown on invoices
|
|
2232
|
+
tax_id_type: tax ID type shown on invoices (e.g. eu_vat, pl_nip, us_ein)
|
|
2233
|
+
tax_id_value: tax ID value shown on invoices
|
|
2234
|
+
"""
|
|
2235
|
+
return await self._t.call(
|
|
2236
|
+
"billing update",
|
|
2237
|
+
[],
|
|
2238
|
+
[
|
|
2239
|
+
("address-city", "string", address_city),
|
|
2240
|
+
("address-country", "string", address_country),
|
|
2241
|
+
("address-line1", "string", address_line1),
|
|
2242
|
+
("address-line2", "string", address_line2),
|
|
2243
|
+
("address-postal-code", "string", address_postal_code),
|
|
2244
|
+
("address-state", "string", address_state),
|
|
2245
|
+
("business-name", "string", business_name),
|
|
2246
|
+
("email", "string", email),
|
|
2247
|
+
("name", "string", name),
|
|
2248
|
+
("phone", "string", phone),
|
|
2249
|
+
("tax-id-type", "string", tax_id_type),
|
|
2250
|
+
("tax-id-value", "string", tax_id_value),
|
|
2251
|
+
],
|
|
2252
|
+
)
|
|
2253
|
+
|
|
2254
|
+
async def receipts(self, *, from_: str | None = None, to: str | None = None) -> Any:
|
|
2255
|
+
"""Show credit purchase receipts and purchases (this billing account only).
|
|
2256
|
+
|
|
2257
|
+
CLI: ``billing receipts [--from=<YYYY-MM-DD>] [--to=<YYYY-MM-DD>]``
|
|
2258
|
+
|
|
2259
|
+
Args:
|
|
2260
|
+
from_: start of the period, YYYY-MM-DD
|
|
2261
|
+
to: end of the period, YYYY-MM-DD
|
|
2262
|
+
"""
|
|
2263
|
+
return await self._t.call(
|
|
2264
|
+
"billing receipts",
|
|
2265
|
+
[],
|
|
2266
|
+
[("from", "string", from_), ("to", "string", to)],
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
async def statement(self, *, from_: str | None = None, to: str | None = None) -> Any:
|
|
2270
|
+
"""Open a consolidated credit purchase statement.
|
|
2271
|
+
|
|
2272
|
+
CLI: ``billing statement [--from=<YYYY-MM-DD>] [--to=<YYYY-MM-DD>]``
|
|
2273
|
+
|
|
2274
|
+
Args:
|
|
2275
|
+
from_: start of the period, YYYY-MM-DD
|
|
2276
|
+
to: end of the period, YYYY-MM-DD
|
|
2277
|
+
"""
|
|
2278
|
+
return await self._t.call(
|
|
2279
|
+
"billing statement",
|
|
2280
|
+
[],
|
|
2281
|
+
[("from", "string", from_), ("to", "string", to)],
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
class _AsyncBillingCredits:
|
|
2286
|
+
"""``billing credits`` commands."""
|
|
2287
|
+
|
|
2288
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
2289
|
+
self._t = t
|
|
2290
|
+
|
|
2291
|
+
async def __call__(self) -> Any:
|
|
2292
|
+
"""Show Shelley credit balances.
|
|
2293
|
+
|
|
2294
|
+
CLI: ``billing credits [usage|transactions|buy]``
|
|
2295
|
+
"""
|
|
2296
|
+
return await self._t.call("billing credits", [])
|
|
2297
|
+
|
|
2298
|
+
async def usage(self, *, detail: bool = False, group: str | None = None, month: str | None = None) -> Any:
|
|
2299
|
+
"""Show Shelley (LLM) credit spend by model, day, or VM.
|
|
2300
|
+
|
|
2301
|
+
CLI: ``billing credits usage [--month=YYYY-MM] [--group=model|day|box] [--detail]``
|
|
2302
|
+
|
|
2303
|
+
Args:
|
|
2304
|
+
detail: break each group down: models under a day or VM, VMs under a model
|
|
2305
|
+
group: group spend by model, day, or box (server default: model)
|
|
2306
|
+
month: calendar month to report, as YYYY-MM (default: this month, UTC)
|
|
2307
|
+
"""
|
|
2308
|
+
return await self._t.call(
|
|
2309
|
+
"billing credits usage",
|
|
2310
|
+
[],
|
|
2311
|
+
[
|
|
2312
|
+
("detail", "bool", detail or None),
|
|
2313
|
+
("group", "string", group),
|
|
2314
|
+
("month", "string", month),
|
|
2315
|
+
],
|
|
2316
|
+
)
|
|
2317
|
+
|
|
2318
|
+
async def transactions(self, *, limit: str | None = None) -> Any:
|
|
2319
|
+
"""Show your credit purchases and gifts.
|
|
2320
|
+
|
|
2321
|
+
CLI: ``billing credits transactions [--limit=N]``
|
|
2322
|
+
|
|
2323
|
+
Args:
|
|
2324
|
+
limit: how many transactions to show, 1-100 (default 20)
|
|
2325
|
+
"""
|
|
2326
|
+
return await self._t.call("billing credits transactions", [], [("limit", "string", limit)])
|
|
2327
|
+
|
|
2328
|
+
async def buy(self, dollars: str, *, idempotency_key: str | None = None, yes: bool = False) -> Any:
|
|
2329
|
+
"""Buy Shelley credits with your personal payment method.
|
|
2330
|
+
|
|
2331
|
+
CLI: ``billing credits buy <dollars> [--yes]``
|
|
2332
|
+
|
|
2333
|
+
Args:
|
|
2334
|
+
idempotency_key: retry key so a repeated purchase of the same amount charges
|
|
2335
|
+
once
|
|
2336
|
+
yes: skip the confirmation prompt (required when not running interactively)
|
|
2337
|
+
"""
|
|
2338
|
+
return await self._t.call(
|
|
2339
|
+
"billing credits buy",
|
|
2340
|
+
[dollars],
|
|
2341
|
+
[
|
|
2342
|
+
("idempotency-key", "string", idempotency_key),
|
|
2343
|
+
("yes", "bool", yes or None),
|
|
2344
|
+
],
|
|
2345
|
+
)
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
class _AsyncBillingPayment:
|
|
2349
|
+
"""``billing payment`` commands."""
|
|
2350
|
+
|
|
2351
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
2352
|
+
self._t = t
|
|
2353
|
+
|
|
2354
|
+
async def __call__(self) -> Any:
|
|
2355
|
+
"""Show your payment methods.
|
|
2356
|
+
|
|
2357
|
+
CLI: ``billing payment [list]``
|
|
2358
|
+
"""
|
|
2359
|
+
return await self._t.call("billing payment", [])
|
|
2360
|
+
|
|
2361
|
+
async def list(self) -> Any:
|
|
2362
|
+
"""List all payment methods on file.
|
|
2363
|
+
|
|
2364
|
+
CLI: ``billing payment list``
|
|
2365
|
+
"""
|
|
2366
|
+
return await self._t.call("billing payment list", [])
|
|
2367
|
+
|
|
2368
|
+
async def remove(self, ref: str) -> Any:
|
|
2369
|
+
"""Remove a saved payment method.
|
|
2370
|
+
|
|
2371
|
+
CLI: ``billing payment remove <ref>``
|
|
2372
|
+
"""
|
|
2373
|
+
return await self._t.call("billing payment remove", [ref])
|
|
2374
|
+
|
|
2375
|
+
async def default(self, ref: str) -> Any:
|
|
2376
|
+
"""Make a saved payment method the default.
|
|
2377
|
+
|
|
2378
|
+
CLI: ``billing payment default <ref>``
|
|
2379
|
+
"""
|
|
2380
|
+
return await self._t.call("billing payment default", [ref])
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
class _AsyncBillingInvoices:
|
|
2384
|
+
"""``billing invoices`` commands."""
|
|
2385
|
+
|
|
2386
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
2387
|
+
self._t = t
|
|
2388
|
+
|
|
2389
|
+
async def __call__(self) -> Any:
|
|
2390
|
+
"""Show recent invoices.
|
|
2391
|
+
|
|
2392
|
+
CLI: ``billing invoices``
|
|
2393
|
+
"""
|
|
2394
|
+
return await self._t.call("billing invoices", [])
|
|
2395
|
+
|
|
2396
|
+
async def list(self, *, account: str | None = None) -> Any:
|
|
2397
|
+
"""List invoices for your personal or team account.
|
|
2398
|
+
|
|
2399
|
+
CLI: ``billing invoices list [--account=personal|team]``
|
|
2400
|
+
|
|
2401
|
+
Args:
|
|
2402
|
+
account: account whose invoices to list: personal or team (server default:
|
|
2403
|
+
personal)
|
|
2404
|
+
"""
|
|
2405
|
+
return await self._t.call("billing invoices list", [], [("account", "string", account)])
|
|
2406
|
+
|
|
2407
|
+
async def show(self, id: str, *, vms: bool = False) -> Any:
|
|
2408
|
+
"""Show one invoice with its usage breakdown.
|
|
2409
|
+
|
|
2410
|
+
CLI: ``billing invoices show <id> [--vms]``
|
|
2411
|
+
|
|
2412
|
+
Args:
|
|
2413
|
+
vms: list VMs without a pool individually
|
|
2414
|
+
"""
|
|
2415
|
+
return await self._t.call("billing invoices show", [id], [("vms", "bool", vms or None)])
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
class _AsyncBillingProvider:
|
|
2419
|
+
"""``billing provider`` commands."""
|
|
2420
|
+
|
|
2421
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
2422
|
+
self._t = t
|
|
2423
|
+
|
|
2424
|
+
async def link(
|
|
2425
|
+
self,
|
|
2426
|
+
provider: Literal["aws", "azure"],
|
|
2427
|
+
*,
|
|
2428
|
+
size: str | None = None,
|
|
2429
|
+
team_name: str | None = None,
|
|
2430
|
+
token: str | None = None,
|
|
2431
|
+
) -> Any:
|
|
2432
|
+
"""Connect a marketplace subscription to your team.
|
|
2433
|
+
|
|
2434
|
+
CLI: ``billing provider link <aws|azure> --token=<link-token> [--size=<team-size>] [--team-name=<name>]``
|
|
2435
|
+
|
|
2436
|
+
Args:
|
|
2437
|
+
size: team size: no marketplace team plans are available
|
|
2438
|
+
team_name: name for the team to create (only when you have no team yet)
|
|
2439
|
+
token: marketplace link token from the exe.dev link page
|
|
2440
|
+
"""
|
|
2441
|
+
return await self._t.call(
|
|
2442
|
+
"billing provider link",
|
|
2443
|
+
[provider],
|
|
2444
|
+
[
|
|
2445
|
+
("size", "string", size),
|
|
2446
|
+
("team-name", "string", team_name),
|
|
2447
|
+
("token", "string", token),
|
|
2448
|
+
],
|
|
2449
|
+
)
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
class _AsyncInvite:
|
|
2453
|
+
"""``invite`` commands."""
|
|
2454
|
+
|
|
2455
|
+
def __init__(self, t: _AsyncTransport) -> None:
|
|
2456
|
+
self._t = t
|
|
2457
|
+
|
|
2458
|
+
async def __call__(self) -> Any:
|
|
2459
|
+
"""Manage your invite link and rewards."""
|
|
2460
|
+
return await self._t.call("invite", [])
|
|
2461
|
+
|
|
2462
|
+
async def show(self) -> Any:
|
|
2463
|
+
"""Show your active invite link and reward.
|
|
2464
|
+
|
|
2465
|
+
CLI: ``invite show``
|
|
2466
|
+
"""
|
|
2467
|
+
return await self._t.call("invite show", [])
|
|
2468
|
+
|
|
2469
|
+
async def link(self) -> Any:
|
|
2470
|
+
"""Print only your active invite link.
|
|
2471
|
+
|
|
2472
|
+
CLI: ``invite link``
|
|
2473
|
+
"""
|
|
2474
|
+
return await self._t.call("invite link", [])
|
|
2475
|
+
|
|
2476
|
+
async def rewards(self) -> Any:
|
|
2477
|
+
"""List invite rewards you can use.
|
|
2478
|
+
|
|
2479
|
+
CLI: ``invite rewards``
|
|
2480
|
+
"""
|
|
2481
|
+
return await self._t.call("invite rewards", [])
|
|
2482
|
+
|
|
2483
|
+
async def set_reward(self, reward: Literal["standard", "bonus-credits", "extra-memory", "extra-disk"]) -> Any:
|
|
2484
|
+
"""Choose the reward for your invite link.
|
|
2485
|
+
|
|
2486
|
+
CLI: ``invite set-reward standard|bonus-credits|extra-memory|extra-disk``
|
|
2487
|
+
"""
|
|
2488
|
+
return await self._t.call("invite set-reward", [reward])
|
|
2489
|
+
|
|
2490
|
+
async def activity(self) -> Any:
|
|
2491
|
+
"""Show signups, upgrades, and reward status.
|
|
2492
|
+
|
|
2493
|
+
CLI: ``invite activity``
|
|
2494
|
+
"""
|
|
2495
|
+
return await self._t.call("invite activity", [])
|
|
2496
|
+
|
|
2497
|
+
async def request(self) -> Any:
|
|
2498
|
+
"""Request more trial invites.
|
|
2499
|
+
|
|
2500
|
+
CLI: ``invite request``
|
|
2501
|
+
"""
|
|
2502
|
+
return await self._t.call("invite request", [])
|
|
2503
|
+
|
|
2504
|
+
async def manage(self) -> Any:
|
|
2505
|
+
"""Open the invites page.
|
|
2506
|
+
|
|
2507
|
+
CLI: ``invite manage``
|
|
2508
|
+
"""
|
|
2509
|
+
return await self._t.call("invite manage", [])
|
|
2510
|
+
|
|
2511
|
+
|
|
2512
|
+
class _AsyncVMDomain:
|
|
2513
|
+
"""The VM's ``domain`` commands."""
|
|
2514
|
+
|
|
2515
|
+
def __init__(self, vm: AsyncVM) -> None:
|
|
2516
|
+
self._vm = vm
|
|
2517
|
+
|
|
2518
|
+
async def add(self, domain: str, *, wildcard: bool = False) -> t.DomainAddResponse:
|
|
2519
|
+
"""Link a custom domain to a VM.
|
|
2520
|
+
|
|
2521
|
+
CLI: ``domain add [--wildcard] <vm> <domain>``
|
|
2522
|
+
|
|
2523
|
+
Args:
|
|
2524
|
+
wildcard: issue a wildcard (*.<parent>) certificate via DNS-01 delegation
|
|
2525
|
+
"""
|
|
2526
|
+
result: t.DomainAddResponse = await self._vm._t.call(
|
|
2527
|
+
"domain add",
|
|
2528
|
+
[self._vm.name, domain],
|
|
2529
|
+
[("wildcard", "bool", wildcard or None)],
|
|
2530
|
+
json=True,
|
|
2531
|
+
)
|
|
2532
|
+
return result
|
|
2533
|
+
|
|
2534
|
+
async def remove(self, domain: str) -> t.DomainRmResponse:
|
|
2535
|
+
"""Remove a custom domain from a VM.
|
|
2536
|
+
|
|
2537
|
+
CLI: ``domain rm <vm> <domain>``
|
|
2538
|
+
"""
|
|
2539
|
+
result: t.DomainRmResponse = await self._vm._t.call("domain rm", [self._vm.name, domain], json=True)
|
|
2540
|
+
return result
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
class _AsyncVMShare:
|
|
2544
|
+
"""The VM's ``share`` commands."""
|
|
2545
|
+
|
|
2546
|
+
def __init__(self, vm: AsyncVM) -> None:
|
|
2547
|
+
self._vm = vm
|
|
2548
|
+
|
|
2549
|
+
async def show(self, *, qr: bool = False) -> t.ShareShowResponse:
|
|
2550
|
+
"""Show current shares for a VM.
|
|
2551
|
+
|
|
2552
|
+
CLI: ``share show <vm>``
|
|
2553
|
+
|
|
2554
|
+
Tag-scoped API keys may run this command.
|
|
2555
|
+
|
|
2556
|
+
Args:
|
|
2557
|
+
qr: show QR code for the URL
|
|
2558
|
+
"""
|
|
2559
|
+
result: t.ShareShowResponse = await self._vm._t.call("share show", [self._vm.name], [("qr", "bool", qr or None)], json=True)
|
|
2560
|
+
return result
|
|
2561
|
+
|
|
2562
|
+
async def port(self, port: int | str | None = None) -> t.SharePortResponse:
|
|
2563
|
+
"""Set the HTTP proxy port for a VM.
|
|
2564
|
+
|
|
2565
|
+
CLI: ``share port <vm> [port]``
|
|
2566
|
+
|
|
2567
|
+
Tag-scoped API keys may run this command.
|
|
2568
|
+
"""
|
|
2569
|
+
result: t.SharePortResponse = await self._vm._t.call("share port", [self._vm.name, port], json=True)
|
|
2570
|
+
return result
|
|
2571
|
+
|
|
2572
|
+
async def set_public(self) -> t.ShareRouteUpdate:
|
|
2573
|
+
"""Make the HTTP proxy publicly accessible.
|
|
2574
|
+
|
|
2575
|
+
CLI: ``share set-public <vm>``
|
|
2576
|
+
|
|
2577
|
+
Tag-scoped API keys may run this command.
|
|
2578
|
+
"""
|
|
2579
|
+
result: t.ShareRouteUpdate = await self._vm._t.call("share set-public", [self._vm.name], json=True)
|
|
2580
|
+
return result
|
|
2581
|
+
|
|
2582
|
+
async def set_private(self) -> t.ShareRouteUpdate:
|
|
2583
|
+
"""Restrict the HTTP proxy to authenticated users.
|
|
2584
|
+
|
|
2585
|
+
CLI: ``share set-private <vm>``
|
|
2586
|
+
|
|
2587
|
+
Tag-scoped API keys may run this command.
|
|
2588
|
+
"""
|
|
2589
|
+
result: t.ShareRouteUpdate = await self._vm._t.call("share set-private", [self._vm.name], json=True)
|
|
2590
|
+
return result
|
|
2591
|
+
|
|
2592
|
+
async def add(
|
|
2593
|
+
self,
|
|
2594
|
+
email: str,
|
|
2595
|
+
*,
|
|
2596
|
+
message: str | None = None,
|
|
2597
|
+
qr: bool = False,
|
|
2598
|
+
root: bool = False,
|
|
2599
|
+
) -> t.ShareAddResponse:
|
|
2600
|
+
"""Share VM with a user via email, or grant shell access with ``root=True``.
|
|
2601
|
+
|
|
2602
|
+
CLI: ``share add <vm> <email|team> [--root] [--message='...']``
|
|
2603
|
+
|
|
2604
|
+
Tag-scoped API keys may run this command.
|
|
2605
|
+
|
|
2606
|
+
Args:
|
|
2607
|
+
message: message to include in share invitation
|
|
2608
|
+
qr: show QR code for the URL
|
|
2609
|
+
root: grant/revoke shell (SSH, Terminal, Shelley) access instead of web-only
|
|
2610
|
+
"""
|
|
2611
|
+
result: t.ShareAddResponse = await self._vm._t.call(
|
|
2612
|
+
"share add",
|
|
2613
|
+
[self._vm.name, email],
|
|
2614
|
+
[
|
|
2615
|
+
("message", "string", message),
|
|
2616
|
+
("qr", "bool", qr or None),
|
|
2617
|
+
("root", "bool", root or None),
|
|
2618
|
+
],
|
|
2619
|
+
json=True,
|
|
2620
|
+
)
|
|
2621
|
+
return result
|
|
2622
|
+
|
|
2623
|
+
async def remove(self, email: str, *, root: bool = False) -> t.ShareRemoveResponse:
|
|
2624
|
+
"""Revoke a user's access to a VM, or downgrade shell access to web with ``root=True``.
|
|
2625
|
+
|
|
2626
|
+
CLI: ``share remove <vm> <email|team> [--root]``
|
|
2627
|
+
|
|
2628
|
+
Tag-scoped API keys may run this command.
|
|
2629
|
+
|
|
2630
|
+
Args:
|
|
2631
|
+
root: grant/revoke shell (SSH, Terminal, Shelley) access instead of web-only
|
|
2632
|
+
"""
|
|
2633
|
+
result: t.ShareRemoveResponse = await self._vm._t.call(
|
|
2634
|
+
"share remove",
|
|
2635
|
+
[self._vm.name, email],
|
|
2636
|
+
[("root", "bool", root or None)],
|
|
2637
|
+
json=True,
|
|
2638
|
+
)
|
|
2639
|
+
return result
|
|
2640
|
+
|
|
2641
|
+
async def add_link(self, *, qr: bool = False) -> t.ShareLinkCreated:
|
|
2642
|
+
"""Create a shareable link for a VM.
|
|
2643
|
+
|
|
2644
|
+
CLI: ``share add-link <vm>``
|
|
2645
|
+
|
|
2646
|
+
Tag-scoped API keys may run this command.
|
|
2647
|
+
|
|
2648
|
+
Args:
|
|
2649
|
+
qr: show QR code for the URL
|
|
2650
|
+
"""
|
|
2651
|
+
result: t.ShareLinkCreated = await self._vm._t.call(
|
|
2652
|
+
"share add-link",
|
|
2653
|
+
[self._vm.name],
|
|
2654
|
+
[("qr", "bool", qr or None)],
|
|
2655
|
+
json=True,
|
|
2656
|
+
)
|
|
2657
|
+
return result
|
|
2658
|
+
|
|
2659
|
+
async def remove_link(self, token: str) -> t.ShareMessage:
|
|
2660
|
+
"""Revoke a shareable link.
|
|
2661
|
+
|
|
2662
|
+
CLI: ``share remove-link <vm> <token>``
|
|
2663
|
+
|
|
2664
|
+
Tag-scoped API keys may run this command.
|
|
2665
|
+
"""
|
|
2666
|
+
result: t.ShareMessage = await self._vm._t.call("share remove-link", [self._vm.name, token], json=True)
|
|
2667
|
+
return result
|
|
2668
|
+
|
|
2669
|
+
async def receive_email(
|
|
2670
|
+
self,
|
|
2671
|
+
enabled: bool | None = None,
|
|
2672
|
+
*,
|
|
2673
|
+
reply_policy: str | None = None,
|
|
2674
|
+
) -> t.ShareReceiveEmailResponse:
|
|
2675
|
+
"""Enable or disable inbound email for a VM.
|
|
2676
|
+
|
|
2677
|
+
CLI: ``share receive-email <vm> [on|off] [--reply-policy=all|known|owner|none]``
|
|
2678
|
+
|
|
2679
|
+
Tag-scoped API keys may run this command.
|
|
2680
|
+
|
|
2681
|
+
Args:
|
|
2682
|
+
reply_policy: restrict who the VM may email: all|known|owner|none
|
|
2683
|
+
"""
|
|
2684
|
+
result: t.ShareReceiveEmailResponse = await self._vm._t.call(
|
|
2685
|
+
"share receive-email",
|
|
2686
|
+
[self._vm.name, on_off(enabled)],
|
|
2687
|
+
[("reply-policy", "string", reply_policy)],
|
|
2688
|
+
json=True,
|
|
2689
|
+
)
|
|
2690
|
+
return result
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
class _AsyncVMShelley:
|
|
2694
|
+
"""The VM's ``shelley`` commands."""
|
|
2695
|
+
|
|
2696
|
+
def __init__(self, vm: AsyncVM) -> None:
|
|
2697
|
+
self._vm = vm
|
|
2698
|
+
|
|
2699
|
+
async def install(self) -> t.ShelleyInstallResponse:
|
|
2700
|
+
"""Install or upgrade Shelley to the current version.
|
|
2701
|
+
|
|
2702
|
+
CLI: ``shelley install <vm>``
|
|
2703
|
+
|
|
2704
|
+
Tag-scoped API keys may run this command.
|
|
2705
|
+
"""
|
|
2706
|
+
result: t.ShelleyInstallResponse = await self._vm._t.call("shelley install", [self._vm.name], json=True)
|
|
2707
|
+
return result
|