akernel-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- akernel_sdk/__init__.py +71 -0
- akernel_sdk/_addresses.py +166 -0
- akernel_sdk/_instance.py +416 -0
- akernel_sdk/_openyuanrong.py +320 -0
- akernel_sdk/_pty_transport.py +304 -0
- akernel_sdk/cli.py +549 -0
- akernel_sdk/commands.py +207 -0
- akernel_sdk/filesystem.py +291 -0
- akernel_sdk/pty.py +250 -0
- akernel_sdk/py.typed +0 -0
- akernel_sdk/sandbox.py +354 -0
- akernel_sdk/types.py +204 -0
- akernel_sdk-0.1.0.dist-info/METADATA +390 -0
- akernel_sdk-0.1.0.dist-info/RECORD +17 -0
- akernel_sdk-0.1.0.dist-info/WHEEL +5 -0
- akernel_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- akernel_sdk-0.1.0.dist-info/top_level.txt +1 -0
akernel_sdk/cli.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# Copyright (c) 2026 Ant Group Corporation.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""AKernel SDK CLI — ak command for managing sandbox instances."""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import ssl
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from urllib import request
|
|
25
|
+
from urllib.error import HTTPError, URLError
|
|
26
|
+
|
|
27
|
+
from ._addresses import Endpoint, api_endpoint_from_env
|
|
28
|
+
from .pty import Pty, PtyError
|
|
29
|
+
|
|
30
|
+
DEFAULT_COMMAND = "/bin/bash"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_auth_token() -> str:
|
|
34
|
+
"""Return the configured token or exit with a user-facing error."""
|
|
35
|
+
|
|
36
|
+
token = os.environ.get("AKERNEL_TOKEN", "").strip()
|
|
37
|
+
if not token:
|
|
38
|
+
print("Error: AKERNEL_TOKEN is not set", file=sys.stderr)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
return token
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_endpoint(configure) -> Endpoint:
|
|
44
|
+
try:
|
|
45
|
+
return configure()
|
|
46
|
+
except RuntimeError as error:
|
|
47
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _create_ssl_context() -> ssl.SSLContext:
|
|
52
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
53
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
54
|
+
context.check_hostname = False
|
|
55
|
+
context.verify_mode = ssl.CERT_NONE
|
|
56
|
+
return context
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _make_get_request(url: str, token: str, ssl_context: ssl.SSLContext) -> dict:
|
|
60
|
+
req = request.Request(url, method="GET", headers={"X-AUTH": token})
|
|
61
|
+
try:
|
|
62
|
+
with request.urlopen(req, context=ssl_context) as response:
|
|
63
|
+
return {"status": response.status, "body": response.read().decode("utf-8")}
|
|
64
|
+
except HTTPError as e:
|
|
65
|
+
return {"status": e.code, "body": e.read().decode("utf-8")}
|
|
66
|
+
except URLError as e:
|
|
67
|
+
print(f"Error: failed to send request: {e}", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _make_json_request(
|
|
72
|
+
url: str,
|
|
73
|
+
token: str,
|
|
74
|
+
ssl_context: ssl.SSLContext,
|
|
75
|
+
payload: dict,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Send an authenticated JSON request and return status and body."""
|
|
78
|
+
|
|
79
|
+
req = request.Request(
|
|
80
|
+
url,
|
|
81
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
82
|
+
method="POST",
|
|
83
|
+
headers={
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"X-Auth": token,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
with request.urlopen(req, context=ssl_context) as response:
|
|
90
|
+
return {
|
|
91
|
+
"status": response.status,
|
|
92
|
+
"body": response.read().decode("utf-8"),
|
|
93
|
+
}
|
|
94
|
+
except HTTPError as error:
|
|
95
|
+
return {
|
|
96
|
+
"status": error.code,
|
|
97
|
+
"body": error.read().decode("utf-8"),
|
|
98
|
+
}
|
|
99
|
+
except URLError as error:
|
|
100
|
+
raise RuntimeError(f"failed to send request: {error}") from error
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fmt_cpu(millicores: float) -> str:
|
|
104
|
+
"""Format CPU value: use cores for >=1 core, millicores otherwise."""
|
|
105
|
+
if millicores == 0:
|
|
106
|
+
return "0"
|
|
107
|
+
cores = millicores / 1000.0
|
|
108
|
+
if cores >= 1.0:
|
|
109
|
+
return f"{cores:.1f}"
|
|
110
|
+
return f"{millicores:.0f}m"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _fmt_mem(mb: float) -> str:
|
|
114
|
+
"""Format memory value in GB."""
|
|
115
|
+
return f"{mb / 1024:.1f}G"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _extract_scalar_value(proto_scalar) -> float:
|
|
119
|
+
"""Extract a numeric value from a protobuf-JSON scalar field.
|
|
120
|
+
|
|
121
|
+
The scalar may be ``{"value": 16000.0}`` (when nested inside Resources),
|
|
122
|
+
a plain number ``16000``, or a string ``"16000"``.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(proto_scalar, dict):
|
|
125
|
+
return float(proto_scalar.get("value", 0))
|
|
126
|
+
if isinstance(proto_scalar, str):
|
|
127
|
+
return float(proto_scalar)
|
|
128
|
+
return float(proto_scalar)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_resources(proto_resources: dict) -> dict:
|
|
132
|
+
"""Convert a protobuf-JSON ``Resources`` map to ``{name: value}`` dict.
|
|
133
|
+
|
|
134
|
+
Protobuf shape::
|
|
135
|
+
|
|
136
|
+
{"CPU": {"scalar": {"value": 16000.0}}, "Memory": {...}}
|
|
137
|
+
|
|
138
|
+
Returns a flat dict like ``{"CPU": 16000.0, "Memory": 32116.0}``.
|
|
139
|
+
"""
|
|
140
|
+
result: dict[str, float] = {}
|
|
141
|
+
if not proto_resources:
|
|
142
|
+
return result
|
|
143
|
+
# Resources message uses a nested "resources" map
|
|
144
|
+
inner = proto_resources.get("resources", proto_resources)
|
|
145
|
+
for name, r in inner.items():
|
|
146
|
+
if isinstance(r, dict):
|
|
147
|
+
result[name] = _extract_scalar_value(r.get("scalar", r))
|
|
148
|
+
elif isinstance(r, (int, float)):
|
|
149
|
+
result[name] = float(r)
|
|
150
|
+
else:
|
|
151
|
+
result[name] = float(r)
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _extract_labels(proto_labels: dict) -> dict:
|
|
156
|
+
"""Convert protobuf-JSON nodeLabels to ``{key: [values...]}`` dict.
|
|
157
|
+
|
|
158
|
+
Protobuf shape::
|
|
159
|
+
|
|
160
|
+
{"NODE_ID": {"items": {"7d90...": 1}}, "HOST_IP": {"items": {"192.168...": 1}}}
|
|
161
|
+
"""
|
|
162
|
+
result: dict[str, list[str]] = {}
|
|
163
|
+
if not proto_labels:
|
|
164
|
+
return result
|
|
165
|
+
for key, counter in proto_labels.items():
|
|
166
|
+
items = counter.get("items", {}) if isinstance(counter, dict) else {}
|
|
167
|
+
result[key] = list(items.keys())
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def handle_resources(debug: bool = False):
|
|
172
|
+
"""Query cluster resource information and display in table format."""
|
|
173
|
+
endpoint = _get_endpoint(api_endpoint_from_env)
|
|
174
|
+
token = _get_auth_token()
|
|
175
|
+
|
|
176
|
+
url = f"{endpoint.base_url()}/global-scheduler/resources"
|
|
177
|
+
req = request.Request(url, method="GET", headers={"X-Auth": token, "Type": "json"})
|
|
178
|
+
ssl_context = _create_ssl_context()
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
with request.urlopen(req, context=ssl_context) as response:
|
|
182
|
+
body = response.read().decode("utf-8")
|
|
183
|
+
status = response.status
|
|
184
|
+
except HTTPError as e:
|
|
185
|
+
body = e.read().decode("utf-8")
|
|
186
|
+
status = e.code
|
|
187
|
+
except URLError as e:
|
|
188
|
+
print(f"Error: failed to send request: {e}", file=sys.stderr)
|
|
189
|
+
sys.exit(1)
|
|
190
|
+
|
|
191
|
+
if status != 200:
|
|
192
|
+
print(f"Error: server returned status {status}", file=sys.stderr)
|
|
193
|
+
print(f"Response: {body}", file=sys.stderr)
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
data = json.loads(body)
|
|
198
|
+
except json.JSONDecodeError as e:
|
|
199
|
+
print(f"Error: failed to parse response: {e}", file=sys.stderr)
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
|
|
202
|
+
if debug:
|
|
203
|
+
print("=== Raw JSON Response ===")
|
|
204
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
205
|
+
print()
|
|
206
|
+
|
|
207
|
+
# Response is QueryResourcesInfoResponse: {"requestID": "...", "resource": {...}}
|
|
208
|
+
resource = data.get("resource", data) if isinstance(data, dict) else None
|
|
209
|
+
if resource is None:
|
|
210
|
+
print("No resource data in response.")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# The top-level resource is the domain scheduler (aggregate).
|
|
214
|
+
# Actual nodes live in resource.fragment as a map of nodeId → ResourceUnit.
|
|
215
|
+
fragment = resource.get("fragment", {}) if isinstance(resource, dict) else {}
|
|
216
|
+
if fragment:
|
|
217
|
+
units = list(fragment.values())
|
|
218
|
+
else:
|
|
219
|
+
units = [resource] if isinstance(resource, dict) else resource
|
|
220
|
+
|
|
221
|
+
# ── Summary accumulators ──
|
|
222
|
+
total_cpu_total = 0.0
|
|
223
|
+
total_cpu_used = 0.0
|
|
224
|
+
total_mem_total = 0.0
|
|
225
|
+
total_mem_used = 0.0
|
|
226
|
+
|
|
227
|
+
# ── Build per-node rows ──
|
|
228
|
+
headers = ["ID", "STATUS", "CPU", "CPU USED", "CPU%",
|
|
229
|
+
"MEM", "MEM USED", "MEM%", "HOST IP"]
|
|
230
|
+
rows = []
|
|
231
|
+
for u in units:
|
|
232
|
+
nid = u.get("id", "-")
|
|
233
|
+
status_val = u.get("status", 0)
|
|
234
|
+
st = "OK" if status_val == 0 else str(status_val)
|
|
235
|
+
|
|
236
|
+
capacity = _extract_resources(u.get("capacity", {}))
|
|
237
|
+
allocatable = _extract_resources(u.get("allocatable", {}))
|
|
238
|
+
labels = _extract_labels(u.get("nodeLabels", {}))
|
|
239
|
+
|
|
240
|
+
cpu_total = capacity.get("CPU", 0) # millicores
|
|
241
|
+
cpu_al = allocatable.get("CPU", 0) # millicores
|
|
242
|
+
cpu_used = cpu_total - cpu_al # millicores
|
|
243
|
+
|
|
244
|
+
# Skip nodes with less than 1 core capacity
|
|
245
|
+
if cpu_total < 1000:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
mem_total = capacity.get("Memory", 0)
|
|
249
|
+
mem_al = allocatable.get("Memory", 0)
|
|
250
|
+
mem_used = mem_total - mem_al
|
|
251
|
+
host_ips = labels.get("HOST_IP", ["-"])
|
|
252
|
+
host_ip = host_ips[0] if host_ips else "-"
|
|
253
|
+
|
|
254
|
+
total_cpu_total += cpu_total / 1000.0
|
|
255
|
+
total_cpu_used += cpu_used / 1000.0
|
|
256
|
+
total_mem_total += mem_total
|
|
257
|
+
total_mem_used += mem_used
|
|
258
|
+
|
|
259
|
+
cpu_usage = (cpu_used / cpu_total) * 100 if cpu_total > 0 else 0
|
|
260
|
+
mem_usage = (mem_used / mem_total) * 100 if mem_total > 0 else 0
|
|
261
|
+
|
|
262
|
+
rows.append([
|
|
263
|
+
nid, st,
|
|
264
|
+
_fmt_cpu(cpu_total), _fmt_cpu(cpu_used),
|
|
265
|
+
f"{cpu_usage:.1f}%",
|
|
266
|
+
_fmt_mem(mem_total), _fmt_mem(mem_used),
|
|
267
|
+
f"{mem_usage:.1f}%",
|
|
268
|
+
host_ip,
|
|
269
|
+
])
|
|
270
|
+
|
|
271
|
+
# Calculate column widths
|
|
272
|
+
col_widths = [len(h) for h in headers]
|
|
273
|
+
for row in rows:
|
|
274
|
+
for i, cell in enumerate(row):
|
|
275
|
+
col_widths[i] = max(col_widths[i], len(cell))
|
|
276
|
+
|
|
277
|
+
def fmt_row(cells):
|
|
278
|
+
return " ".join(c.rjust(col_widths[i]) for i, c in enumerate(cells))
|
|
279
|
+
|
|
280
|
+
# ── Summary ──
|
|
281
|
+
cluster_cpu_usage = (
|
|
282
|
+
(total_cpu_used / total_cpu_total) * 100 if total_cpu_total > 0 else 0
|
|
283
|
+
)
|
|
284
|
+
cluster_mem_usage = (
|
|
285
|
+
(total_mem_used / total_mem_total) * 100 if total_mem_total > 0 else 0
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
print("=== Cluster Resource Summary ===")
|
|
289
|
+
print(f" Nodes: {len(rows)}")
|
|
290
|
+
print(f" CPU total: {total_cpu_total:.1f} cores")
|
|
291
|
+
print(f" CPU used: {total_cpu_used:.1f} cores ({cluster_cpu_usage:.1f}%)")
|
|
292
|
+
print(f" MEM total: {_fmt_mem(total_mem_total)}")
|
|
293
|
+
print(f" MEM used: {_fmt_mem(total_mem_used)} ({cluster_mem_usage:.1f}%)")
|
|
294
|
+
print()
|
|
295
|
+
|
|
296
|
+
# ── Per-node table ──
|
|
297
|
+
# Print header (left-aligned) and separator
|
|
298
|
+
print(" ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)))
|
|
299
|
+
print(" ".join("-" * w for w in col_widths))
|
|
300
|
+
for row in rows:
|
|
301
|
+
print(fmt_row(row))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def handle_list(quiet: bool = False):
|
|
305
|
+
"""List all running instances.
|
|
306
|
+
|
|
307
|
+
When *quiet* is set, print only instance IDs (one per line) so the output
|
|
308
|
+
pipes cleanly into ``xargs ak delete``.
|
|
309
|
+
"""
|
|
310
|
+
endpoint = _get_endpoint(api_endpoint_from_env)
|
|
311
|
+
token = _get_auth_token()
|
|
312
|
+
ssl_context = _create_ssl_context()
|
|
313
|
+
|
|
314
|
+
list_url = f"{endpoint.base_url()}/api/instances?tenant_id=default"
|
|
315
|
+
result = _make_get_request(list_url, token, ssl_context)
|
|
316
|
+
|
|
317
|
+
if result["status"] != 200:
|
|
318
|
+
print(f"Error: server returned status {result['status']}", file=sys.stderr)
|
|
319
|
+
print(f"Response: {result['body']}", file=sys.stderr)
|
|
320
|
+
sys.exit(1)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
instances = json.loads(result["body"])
|
|
324
|
+
except json.JSONDecodeError as e:
|
|
325
|
+
print(f"Error: failed to parse response: {e}", file=sys.stderr)
|
|
326
|
+
sys.exit(1)
|
|
327
|
+
|
|
328
|
+
running = [inst for inst in instances if inst.get("status") == "running"]
|
|
329
|
+
|
|
330
|
+
if quiet:
|
|
331
|
+
for inst in running:
|
|
332
|
+
inst_id = inst.get("id", "")
|
|
333
|
+
if inst_id:
|
|
334
|
+
print(inst_id)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
if not running:
|
|
338
|
+
print("No running instances found.")
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
# Print header
|
|
342
|
+
id_width = max(len(inst.get("id", "")) for inst in running)
|
|
343
|
+
id_width = max(id_width, 2)
|
|
344
|
+
|
|
345
|
+
print(f"{'ID':<{id_width}} STATUS")
|
|
346
|
+
print(f"{'-' * id_width} ------")
|
|
347
|
+
for inst in running:
|
|
348
|
+
inst_id = inst.get("id", "unknown")
|
|
349
|
+
status = inst.get("status", "unknown")
|
|
350
|
+
print(f"{inst_id:<{id_width}} {status}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def handle_delete(instance_ids: list[str]) -> None:
|
|
354
|
+
"""Terminate sandbox instances through the frontend actor API."""
|
|
355
|
+
|
|
356
|
+
endpoint = _get_endpoint(api_endpoint_from_env)
|
|
357
|
+
token = _get_auth_token()
|
|
358
|
+
ssl_context = _create_ssl_context()
|
|
359
|
+
failed = []
|
|
360
|
+
for instance_id in instance_ids:
|
|
361
|
+
try:
|
|
362
|
+
result = _make_json_request(
|
|
363
|
+
f"{endpoint.base_url()}/frontend/v1/instance/kill",
|
|
364
|
+
token,
|
|
365
|
+
ssl_context,
|
|
366
|
+
{"instanceID": instance_id, "signal": 1},
|
|
367
|
+
)
|
|
368
|
+
if result["status"] != 200:
|
|
369
|
+
raise RuntimeError(
|
|
370
|
+
f"server returned status {result['status']}: {result['body']}"
|
|
371
|
+
)
|
|
372
|
+
try:
|
|
373
|
+
body = json.loads(result["body"])
|
|
374
|
+
except json.JSONDecodeError as error:
|
|
375
|
+
raise RuntimeError(f"invalid server response: {error}") from error
|
|
376
|
+
if not isinstance(body, dict):
|
|
377
|
+
raise RuntimeError("invalid server response: expected a JSON object")
|
|
378
|
+
code = int(body.get("code", -1))
|
|
379
|
+
if code != 0:
|
|
380
|
+
message = body.get("message") or "unknown error"
|
|
381
|
+
raise RuntimeError(f"server returned code {code}: {message}")
|
|
382
|
+
print(f"deleted: {instance_id}")
|
|
383
|
+
except Exception as error:
|
|
384
|
+
print(f"failed to delete {instance_id}: {error}", file=sys.stderr)
|
|
385
|
+
failed.append(instance_id)
|
|
386
|
+
|
|
387
|
+
if failed:
|
|
388
|
+
sys.exit(1)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class _RawTerminal:
|
|
392
|
+
"""Context manager for raw terminal mode."""
|
|
393
|
+
|
|
394
|
+
def __init__(self, fd):
|
|
395
|
+
import termios
|
|
396
|
+
|
|
397
|
+
self.fd = fd
|
|
398
|
+
self.old = termios.tcgetattr(fd)
|
|
399
|
+
|
|
400
|
+
def __enter__(self):
|
|
401
|
+
import tty
|
|
402
|
+
|
|
403
|
+
tty.setraw(self.fd)
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
def __exit__(self, *exc):
|
|
407
|
+
import termios
|
|
408
|
+
|
|
409
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _terminal_size() -> tuple[int, int]:
|
|
413
|
+
import shutil
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
terminal_size = shutil.get_terminal_size()
|
|
417
|
+
return terminal_size.lines, terminal_size.columns
|
|
418
|
+
except Exception:
|
|
419
|
+
return 24, 80
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _write_terminal_data(data: bytes) -> None:
|
|
423
|
+
sys.stdout.buffer.write(data)
|
|
424
|
+
sys.stdout.buffer.flush()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def handle_exec(instance_id: str, command: str | Sequence[str]) -> int:
|
|
428
|
+
"""Attach the local terminal to an interactive sandbox process."""
|
|
429
|
+
|
|
430
|
+
import contextlib
|
|
431
|
+
import select
|
|
432
|
+
import signal
|
|
433
|
+
|
|
434
|
+
rows, cols = _terminal_size()
|
|
435
|
+
resize_pending = threading.Event()
|
|
436
|
+
previous_sigwinch = None
|
|
437
|
+
|
|
438
|
+
print(f"Connecting to {instance_id}...", file=sys.stderr)
|
|
439
|
+
print("Press Ctrl+] to disconnect", file=sys.stderr)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
session = Pty(instance_id).create(
|
|
443
|
+
command,
|
|
444
|
+
rows=rows,
|
|
445
|
+
cols=cols,
|
|
446
|
+
on_data=_write_terminal_data,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def request_resize(_signum, _frame):
|
|
450
|
+
resize_pending.set()
|
|
451
|
+
|
|
452
|
+
previous_sigwinch = signal.signal(signal.SIGWINCH, request_resize)
|
|
453
|
+
stdin_fd = sys.stdin.fileno()
|
|
454
|
+
terminal_context = (
|
|
455
|
+
_RawTerminal(stdin_fd) if sys.stdin.isatty() else contextlib.nullcontext()
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
with session, terminal_context:
|
|
459
|
+
while not session.done:
|
|
460
|
+
if resize_pending.is_set():
|
|
461
|
+
resize_pending.clear()
|
|
462
|
+
new_rows, new_cols = _terminal_size()
|
|
463
|
+
session.resize(rows=new_rows, cols=new_cols)
|
|
464
|
+
|
|
465
|
+
ready, _, _ = select.select([stdin_fd], [], [], 0.1)
|
|
466
|
+
if not ready:
|
|
467
|
+
continue
|
|
468
|
+
data = os.read(stdin_fd, 4096)
|
|
469
|
+
if not data:
|
|
470
|
+
session.close_stdin()
|
|
471
|
+
return session.wait()
|
|
472
|
+
if b"\x1d" in data:
|
|
473
|
+
print(
|
|
474
|
+
"\n[Escape sequence detected, disconnecting...]",
|
|
475
|
+
file=sys.stderr,
|
|
476
|
+
)
|
|
477
|
+
session.close()
|
|
478
|
+
return 0
|
|
479
|
+
session.send_stdin(data)
|
|
480
|
+
return session.wait()
|
|
481
|
+
except KeyboardInterrupt:
|
|
482
|
+
print("\nDisconnected", file=sys.stderr)
|
|
483
|
+
return 130
|
|
484
|
+
except (PtyError, TimeoutError, OSError) as error:
|
|
485
|
+
print(f"\nConnection error: {error}", file=sys.stderr)
|
|
486
|
+
return 1
|
|
487
|
+
finally:
|
|
488
|
+
if previous_sigwinch is not None:
|
|
489
|
+
signal.signal(signal.SIGWINCH, previous_sigwinch)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def main():
|
|
493
|
+
parser = argparse.ArgumentParser(prog="ak", description="AKernel SDK CLI")
|
|
494
|
+
sub = parser.add_subparsers(dest="command")
|
|
495
|
+
|
|
496
|
+
resources_parser = sub.add_parser(
|
|
497
|
+
"resources", help="Query cluster resource information"
|
|
498
|
+
)
|
|
499
|
+
resources_parser.add_argument(
|
|
500
|
+
"--debug",
|
|
501
|
+
action="store_true",
|
|
502
|
+
help="Print raw JSON response for debugging",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
list_parser = sub.add_parser("list", help="List all running instances")
|
|
506
|
+
list_parser.add_argument(
|
|
507
|
+
"-q",
|
|
508
|
+
"--quiet",
|
|
509
|
+
action="store_true",
|
|
510
|
+
help="Print only instance IDs (suitable for `ak list -q | xargs ak delete`)",
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
exec_parser = sub.add_parser("exec", help="Execute command in a sandbox instance")
|
|
514
|
+
exec_parser.add_argument("instance_id", help="Instance ID")
|
|
515
|
+
exec_parser.add_argument(
|
|
516
|
+
"cmdline",
|
|
517
|
+
nargs="*",
|
|
518
|
+
default=None,
|
|
519
|
+
help="Command to execute (default: /bin/bash)",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
delete_parser = sub.add_parser(
|
|
523
|
+
"delete", help="Terminate one or more sandbox instances"
|
|
524
|
+
)
|
|
525
|
+
delete_parser.add_argument(
|
|
526
|
+
"instance_ids",
|
|
527
|
+
nargs="+",
|
|
528
|
+
help="Instance IDs to delete (one or more)",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
args = parser.parse_args()
|
|
532
|
+
|
|
533
|
+
if args.command is None:
|
|
534
|
+
parser.print_help()
|
|
535
|
+
sys.exit(0)
|
|
536
|
+
|
|
537
|
+
if args.command == "resources":
|
|
538
|
+
handle_resources(debug=args.debug)
|
|
539
|
+
elif args.command == "list":
|
|
540
|
+
handle_list(quiet=args.quiet)
|
|
541
|
+
elif args.command == "exec":
|
|
542
|
+
cmd = args.cmdline if args.cmdline else [DEFAULT_COMMAND]
|
|
543
|
+
sys.exit(handle_exec(args.instance_id, cmd))
|
|
544
|
+
elif args.command == "delete":
|
|
545
|
+
handle_delete(args.instance_ids)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == "__main__":
|
|
549
|
+
main()
|