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/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()