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.
@@ -0,0 +1,320 @@
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
+ """Internal openYuanrong adapter used by the public AKernel SDK."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import atexit
20
+ import json
21
+ import logging
22
+ import os
23
+ import threading
24
+ from collections.abc import Mapping, Sequence
25
+ from typing import Any
26
+
27
+ import yr
28
+ from yr.runtime_holder import global_runtime
29
+ from yr.sandbox.sandbox import _sanitize_instance_id
30
+
31
+ from ._addresses import api_endpoint_from_env, gateway_endpoint_from_env
32
+ from ._instance import _SandboxInstance
33
+ from .types import HttpReverseTunnel, Mount, NodeInfo, S3Config
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _NAMESPACE = "akernel"
38
+ _initialized = False
39
+ _init_lock = threading.Lock()
40
+
41
+
42
+ def ensure_initialized() -> None:
43
+ """Initialize the openYuanrong client once for the current process."""
44
+
45
+ global _initialized
46
+ if _initialized:
47
+ return
48
+
49
+ with _init_lock:
50
+ if _initialized:
51
+ return
52
+
53
+ token = os.environ.get("AKERNEL_TOKEN", "").strip()
54
+ if not token:
55
+ raise RuntimeError("AKERNEL_TOKEN is not set")
56
+
57
+ endpoint = api_endpoint_from_env()
58
+ # Keep openYuanrong tuning inside its backend initialization boundary.
59
+ os.environ.setdefault("YR_HTTP_CONNECTION_NUM", "2")
60
+ os.environ.setdefault("YR_SERVER_ADDRESS", endpoint.authority())
61
+ gateway_override = os.environ.get("AKERNEL_GATEWAY_ADDRESS", "").strip()
62
+ if gateway_override and not os.environ.get("YR_GATEWAY_ADDRESS", "").strip():
63
+ os.environ["YR_GATEWAY_ADDRESS"] = gateway_override
64
+
65
+ yr.init(
66
+ yr.Config(
67
+ server_address=endpoint.authority(),
68
+ auth_token=token,
69
+ enable_tls=endpoint.use_tls,
70
+ server_name="akernel-sdk",
71
+ in_cluster=False,
72
+ http_ioc_threads_num=8,
73
+ bypass_datasystem=True,
74
+ )
75
+ )
76
+ atexit.register(yr.finalize)
77
+ _initialized = True
78
+
79
+
80
+ def _validate_positive_int(name: str, value: int, *, allow_zero: bool = False) -> None:
81
+ if isinstance(value, bool) or not isinstance(value, int):
82
+ raise TypeError(f"{name} must be an integer")
83
+ minimum = 0 if allow_zero else 1
84
+ if value < minimum:
85
+ comparator = "non-negative" if allow_zero else "greater than 0"
86
+ raise ValueError(f"{name} must be {comparator}")
87
+
88
+
89
+ def _rootfs_json(
90
+ *, image: str | None, rootfs: S3Config | None, runtime: str
91
+ ) -> str | None:
92
+ if image is not None:
93
+ return json.dumps(
94
+ {
95
+ "runtime": runtime,
96
+ "type": "image",
97
+ "readonly": False,
98
+ "imageurl": image,
99
+ }
100
+ )
101
+ if rootfs is not None:
102
+ return json.dumps(
103
+ {
104
+ "runtime": runtime,
105
+ "type": "s3",
106
+ "readonly": False,
107
+ "s3_config": rootfs.to_dict(),
108
+ }
109
+ )
110
+ return None
111
+
112
+
113
+ def build_options(
114
+ *,
115
+ image: str | None,
116
+ rootfs: S3Config | None,
117
+ runtime: str,
118
+ cpu: int,
119
+ memory: int,
120
+ cpu_limit: int,
121
+ mem_limit: int,
122
+ idle_timeout: int,
123
+ schedule_timeout: int,
124
+ env: Mapping[str, str] | None,
125
+ name: str | None,
126
+ port_forwardings: Sequence[int],
127
+ mounts: Sequence[Mount],
128
+ reverse_tunnel: HttpReverseTunnel | None,
129
+ detached: bool,
130
+ node_id: str | None,
131
+ ) -> Any:
132
+ """Translate the stable SDK configuration to openYuanrong options."""
133
+
134
+ _validate_positive_int("cpu", cpu)
135
+ _validate_positive_int("memory", memory)
136
+ _validate_positive_int("cpu_limit", cpu_limit, allow_zero=True)
137
+ _validate_positive_int("mem_limit", mem_limit, allow_zero=True)
138
+ _validate_positive_int("idle_timeout", idle_timeout, allow_zero=True)
139
+ if schedule_timeout != -1:
140
+ _validate_positive_int("schedule_timeout", schedule_timeout, allow_zero=True)
141
+ if cpu_limit and cpu_limit < cpu:
142
+ raise ValueError("cpu_limit must be 0 or greater than or equal to cpu")
143
+ if mem_limit and mem_limit < memory:
144
+ raise ValueError("mem_limit must be 0 or greater than or equal to memory")
145
+
146
+ options = yr.InvokeOptions()
147
+ # A Sandbox is driven by one sequential SDK client. Disabling ordered RPC
148
+ # execution prevents a missing sequence number from stalling later calls.
149
+ options.need_order = False
150
+ options.idle_timeout = idle_timeout
151
+ options.schedule_timeout_ms = (
152
+ -1 if schedule_timeout == -1 else schedule_timeout * 1000
153
+ )
154
+ options.cpu = cpu
155
+ options.memory = memory
156
+ options.cpu_limit = cpu_limit
157
+ options.mem_limit = mem_limit
158
+ options.namespace = _NAMESPACE
159
+
160
+ rootfs_json = _rootfs_json(image=image, rootfs=rootfs, runtime=runtime)
161
+ if rootfs_json is not None:
162
+ options.custom_extensions["rootfs"] = rootfs_json
163
+ if detached:
164
+ options.custom_extensions["lifecycle"] = "detached"
165
+ if env:
166
+ options.env_vars = dict(env)
167
+ if name:
168
+ options.name = name
169
+ if mounts:
170
+ options.custom_extensions["mounts"] = json.dumps(
171
+ [mount.to_dict() for mount in mounts]
172
+ )
173
+
174
+ forwarded = list(port_forwardings)
175
+ if reverse_tunnel is not None:
176
+ forwarded.append(reverse_tunnel.reverse_port)
177
+ if forwarded:
178
+ from yr.config import PortForwarding
179
+
180
+ options.port_forwardings = [
181
+ PortForwarding(port=port, protocol="TCP") for port in forwarded
182
+ ]
183
+
184
+ if node_id is not None:
185
+ if not isinstance(node_id, str) or not node_id.strip():
186
+ raise ValueError("node_id must be a non-empty string")
187
+ operator = yr.LabelOperator(
188
+ yr.OperatorType.LABEL_IN,
189
+ "NODE_ID",
190
+ [node_id.strip()],
191
+ )
192
+ options.schedule_affinities = [
193
+ yr.Affinity(
194
+ yr.AffinityKind.RESOURCE,
195
+ yr.AffinityType.REQUIRED,
196
+ [operator],
197
+ )
198
+ ]
199
+
200
+ return options
201
+
202
+
203
+ def create_instance(options: Any, *, cwd: str | None) -> Any:
204
+ instance_class: Any = _SandboxInstance
205
+ handle = instance_class.options(options).invoke(cwd=cwd)
206
+ yr.get(handle.ping.invoke())
207
+ return handle
208
+
209
+
210
+ def real_instance_id(handle: Any) -> str:
211
+ return global_runtime.get_runtime().get_real_instance_id(handle.instance_id)
212
+
213
+
214
+ def terminate_instance(handle: Any) -> None:
215
+ handle.terminate()
216
+
217
+
218
+ def delete_named_instance(name: str) -> None:
219
+ ensure_initialized()
220
+ handle = yr.get_instance(name, namespace=_NAMESPACE)
221
+ handle.terminate(is_sync=True)
222
+
223
+
224
+ def ping_instance(handle: Any) -> bool:
225
+ try:
226
+ yr.get(handle.ping.invoke())
227
+ return True
228
+ except Exception:
229
+ return False
230
+
231
+
232
+ def instance_state(handle: Any) -> str:
233
+ result = yr.get(handle.get_info.invoke())
234
+ return result.get("state", "unknown")
235
+
236
+
237
+ def start_reverse_tunnel(
238
+ handle: Any,
239
+ tunnel: HttpReverseTunnel,
240
+ *,
241
+ name: str | None,
242
+ ) -> Any:
243
+ """Start both ends of an HTTP reverse tunnel and return its client."""
244
+
245
+ yr.get(
246
+ handle.start_tunnel_server.invoke(
247
+ tunnel.reverse_port,
248
+ tunnel.listen_port,
249
+ )
250
+ )
251
+ gateway = gateway_endpoint_from_env()
252
+ instance_id = real_instance_id(handle)
253
+ safe_id = _sanitize_instance_id(instance_id)
254
+ websocket_url = (
255
+ f"{gateway.websocket_scheme}://"
256
+ f"{gateway.authority(omit_default_port=True)}/"
257
+ f"{safe_id}/{tunnel.reverse_port}"
258
+ )
259
+
260
+ from yr.sandbox.tunnel_client import TunnelClient
261
+
262
+ client = TunnelClient(tunnel.target)
263
+ logger.info(
264
+ "Starting reverse tunnel: sandbox_id=%s name=%s url=%s target=%s",
265
+ safe_id,
266
+ name or "",
267
+ websocket_url,
268
+ tunnel.target,
269
+ )
270
+ if client.start(websocket_url, timeout=float(tunnel.connect_timeout)):
271
+ return client
272
+
273
+ client.stop()
274
+ raise RuntimeError(
275
+ "reverse tunnel connection timed out after "
276
+ f"{float(tunnel.connect_timeout):.1f}s: sandbox_id={safe_id} "
277
+ f"name={name or ''} tunnel_url={websocket_url}"
278
+ )
279
+
280
+
281
+ def _to_float_mapping(value: Any) -> dict[str, float]:
282
+ if not isinstance(value, Mapping):
283
+ return {}
284
+ result: dict[str, float] = {}
285
+ for key, raw in value.items():
286
+ scalar = raw
287
+ if isinstance(raw, Mapping):
288
+ scalar = raw.get("value", raw.get("scalar", raw))
289
+ if isinstance(scalar, Mapping):
290
+ scalar = scalar.get("value", 0)
291
+ try:
292
+ result[str(key)] = float(scalar)
293
+ except (TypeError, ValueError):
294
+ continue
295
+ return result
296
+
297
+
298
+ def _to_node_info(value: Any) -> NodeInfo:
299
+ if not isinstance(value, Mapping):
300
+ raise TypeError(f"unexpected openYuanrong resource entry: {value!r}")
301
+ labels = value.get("labels", value.get("nodeLabels", {}))
302
+ return NodeInfo(
303
+ id=str(value.get("id", "")),
304
+ status=int(value.get("status", 0)),
305
+ capacity=_to_float_mapping(value.get("capacity", {})),
306
+ allocatable=_to_float_mapping(value.get("allocatable", {})),
307
+ labels=dict(labels) if isinstance(labels, Mapping) else {},
308
+ )
309
+
310
+
311
+ def resources() -> list[NodeInfo]:
312
+ """Return cluster resources through stable AKernel value types."""
313
+
314
+ ensure_initialized()
315
+ raw = yr.resources()
316
+ if raw is None:
317
+ return []
318
+ if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
319
+ raise TypeError("openYuanrong resources() returned a non-list value")
320
+ return [_to_node_info(item) for item in raw]
@@ -0,0 +1,304 @@
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
+ """Thread-backed WebSocket transport for the synchronous PTY API."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import threading
22
+ from collections.abc import Callable, Sequence
23
+ from concurrent.futures import Future
24
+ from typing import Any
25
+ from urllib.parse import urlencode
26
+
27
+ from ._addresses import Endpoint
28
+
29
+ _PROTOCOL = "sandbox.pty.v1"
30
+ _PROTOCOL_VERSION = 1
31
+ _WRITE_TIMEOUT = 10.0
32
+
33
+
34
+ class _PtyTransportError(RuntimeError):
35
+ """Internal transport failure converted to PtyError by the public API."""
36
+
37
+
38
+ def _build_pty_uri(
39
+ endpoint: Endpoint,
40
+ *,
41
+ instance_id: str,
42
+ token: str,
43
+ command: Sequence[str],
44
+ rows: int,
45
+ cols: int,
46
+ ) -> str:
47
+ parameters: list[tuple[str, str]] = [
48
+ ("instance", instance_id),
49
+ ("tenant_id", "default"),
50
+ ("token", token),
51
+ ("tty", "true"),
52
+ ("rows", str(rows)),
53
+ ("cols", str(cols)),
54
+ ("protocol", _PROTOCOL),
55
+ ]
56
+ parameters.extend(("command", argument) for argument in command)
57
+ return (
58
+ f"{endpoint.websocket_scheme}://"
59
+ f"{endpoint.authority(omit_default_port=True)}/terminal/ws?"
60
+ f"{urlencode(parameters)}"
61
+ )
62
+
63
+
64
+ class _PtyConnection:
65
+ def __init__(
66
+ self,
67
+ uri: str,
68
+ *,
69
+ ssl_context: Any,
70
+ rows: int,
71
+ cols: int,
72
+ on_data: Callable[[bytes], None] | None,
73
+ on_done: Callable[[], None],
74
+ ) -> None:
75
+ self._uri = uri
76
+ self._ssl_context = ssl_context
77
+ self._rows = rows
78
+ self._cols = cols
79
+ self._on_data = on_data
80
+ self._on_done = on_done
81
+
82
+ self._ready = threading.Event()
83
+ self._done = threading.Event()
84
+ self._state_lock = threading.Lock()
85
+ self._thread = threading.Thread(
86
+ target=self._thread_main,
87
+ name="akernel-pty",
88
+ daemon=True,
89
+ )
90
+ self._loop: asyncio.AbstractEventLoop | None = None
91
+ self._run_task: asyncio.Task[None] | None = None
92
+ self._websocket: Any = None
93
+ self._session_id: str | None = None
94
+ self._exit_code: int | None = None
95
+ self._error: BaseException | None = None
96
+ self._closed_by_client = False
97
+
98
+ @property
99
+ def session_id(self) -> str:
100
+ if self._session_id is None:
101
+ raise _PtyTransportError("PTY session has not started")
102
+ return self._session_id
103
+
104
+ @property
105
+ def exit_code(self) -> int | None:
106
+ return self._exit_code
107
+
108
+ @property
109
+ def done(self) -> bool:
110
+ return self._done.is_set()
111
+
112
+ def start(self, timeout: float) -> None:
113
+ self._thread.start()
114
+ if not self._ready.wait(timeout):
115
+ self.close()
116
+ raise TimeoutError(f"PTY did not start within {timeout:g} seconds")
117
+ self._raise_error()
118
+ if self._session_id is None:
119
+ raise _PtyTransportError("PTY connection closed before it started")
120
+
121
+ def send_stdin(self, data: bytes) -> None:
122
+ self._send(data)
123
+
124
+ def close_stdin(self) -> None:
125
+ self._send("STDIN_EOF")
126
+
127
+ def resize(self, *, rows: int, cols: int) -> None:
128
+ self._send(f"RESIZE:{cols}:{rows}")
129
+
130
+ def wait(self, timeout: float | None) -> int:
131
+ if not self._done.wait(timeout):
132
+ rendered = (
133
+ "the requested timeout"
134
+ if timeout is None
135
+ else f"{timeout:g} seconds"
136
+ )
137
+ raise TimeoutError(f"PTY did not exit within {rendered}")
138
+ self._raise_error()
139
+ if self._exit_code is None:
140
+ if self._closed_by_client:
141
+ raise _PtyTransportError("PTY session was closed by the client")
142
+ raise _PtyTransportError("PTY connection closed without an exit status")
143
+ return self._exit_code
144
+
145
+ def close(self) -> None:
146
+ with self._state_lock:
147
+ if self._closed_by_client:
148
+ return
149
+ self._closed_by_client = True
150
+ loop = self._loop
151
+ run_task = self._run_task
152
+ websocket = self._websocket
153
+
154
+ if loop is not None and websocket is not None and not self._done.is_set():
155
+ future = asyncio.run_coroutine_threadsafe(websocket.close(), loop)
156
+ try:
157
+ future.result(timeout=_WRITE_TIMEOUT)
158
+ except Exception:
159
+ pass
160
+ elif loop is not None and run_task is not None and not run_task.done():
161
+ loop.call_soon_threadsafe(run_task.cancel)
162
+ if self._thread.is_alive() and threading.current_thread() is not self._thread:
163
+ self._thread.join(timeout=_WRITE_TIMEOUT)
164
+
165
+ def _send(self, data: bytes | str) -> None:
166
+ if self._done.is_set():
167
+ self._raise_error()
168
+ raise _PtyTransportError("PTY session is no longer running")
169
+ if threading.current_thread() is self._thread:
170
+ raise _PtyTransportError(
171
+ "PTY methods cannot be called from the on_data callback"
172
+ )
173
+ with self._state_lock:
174
+ loop = self._loop
175
+ websocket = self._websocket
176
+ if loop is None or websocket is None:
177
+ raise _PtyTransportError("PTY transport is not connected")
178
+ future: Future[Any] = asyncio.run_coroutine_threadsafe(
179
+ websocket.send(data), loop
180
+ )
181
+ try:
182
+ future.result(timeout=_WRITE_TIMEOUT)
183
+ except Exception as error:
184
+ raise _PtyTransportError(f"failed to write to PTY: {error}") from error
185
+
186
+ def _thread_main(self) -> None:
187
+ try:
188
+ asyncio.run(self._run())
189
+ except BaseException as error:
190
+ self._set_error(error)
191
+ finally:
192
+ self._ready.set()
193
+ self._done.set()
194
+ try:
195
+ self._on_done()
196
+ except Exception:
197
+ pass
198
+
199
+ async def _run(self) -> None:
200
+ import websockets
201
+
202
+ loop = asyncio.get_running_loop()
203
+ with self._state_lock:
204
+ self._loop = loop
205
+ self._run_task = asyncio.current_task()
206
+
207
+ try:
208
+ async with websockets.connect(
209
+ self._uri,
210
+ ssl=self._ssl_context,
211
+ ping_interval=20,
212
+ ping_timeout=10,
213
+ ) as websocket:
214
+ with self._state_lock:
215
+ self._websocket = websocket
216
+ await websocket.send(f"RESIZE:{self._cols}:{self._rows}")
217
+
218
+ async for message in websocket:
219
+ if isinstance(message, bytes):
220
+ if self._on_data is not None:
221
+ try:
222
+ self._on_data(message)
223
+ except BaseException as error:
224
+ self._set_error(
225
+ _PtyTransportError(
226
+ f"PTY on_data callback failed: {error}"
227
+ )
228
+ )
229
+ await websocket.close()
230
+ return
231
+ continue
232
+ self._handle_control(message)
233
+ if self._done.is_set():
234
+ await websocket.close()
235
+ await websocket.wait_closed()
236
+ return
237
+ except BaseException as error:
238
+ if not self._closed_by_client and not self._done.is_set():
239
+ self._set_error(
240
+ _PtyTransportError(f"PTY WebSocket connection failed: {error}")
241
+ )
242
+ finally:
243
+ with self._state_lock:
244
+ self._websocket = None
245
+ self._loop = None
246
+ self._run_task = None
247
+
248
+ if not self._closed_by_client and not self._done.is_set():
249
+ self._set_error(
250
+ _PtyTransportError("PTY connection closed before terminal status")
251
+ )
252
+
253
+ def _handle_control(self, message: str) -> None:
254
+ try:
255
+ event = json.loads(message)
256
+ except json.JSONDecodeError as error:
257
+ self._set_error(_PtyTransportError(f"invalid PTY control frame: {error}"))
258
+ return
259
+ if not isinstance(event, dict) or event.get("version") != _PROTOCOL_VERSION:
260
+ self._set_error(_PtyTransportError("unsupported PTY control frame"))
261
+ return
262
+
263
+ event_type = event.get("type")
264
+ session_id = event.get("session_id")
265
+ if not isinstance(session_id, str) or not session_id:
266
+ self._set_error(_PtyTransportError("PTY control frame has no session_id"))
267
+ return
268
+
269
+ if event_type == "started":
270
+ self._session_id = session_id
271
+ self._ready.set()
272
+ elif event_type == "exited":
273
+ exit_code = event.get("exit_code")
274
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int):
275
+ self._set_error(_PtyTransportError("PTY exit frame has no exit_code"))
276
+ return
277
+ self._session_id = session_id
278
+ self._exit_code = exit_code
279
+ self._ready.set()
280
+ self._done.set()
281
+ elif event_type == "error":
282
+ message_value = event.get("message")
283
+ detail = (
284
+ message_value if isinstance(message_value, str) else "unknown error"
285
+ )
286
+ self._session_id = session_id
287
+ self._set_error(_PtyTransportError(f"remote PTY error: {detail}"))
288
+ else:
289
+ self._set_error(
290
+ _PtyTransportError(f"unknown PTY control event {event_type!r}")
291
+ )
292
+
293
+ def _set_error(self, error: BaseException) -> None:
294
+ with self._state_lock:
295
+ if self._error is None:
296
+ self._error = error
297
+ self._ready.set()
298
+ self._done.set()
299
+
300
+ def _raise_error(self) -> None:
301
+ with self._state_lock:
302
+ error = self._error
303
+ if error is not None:
304
+ raise error