@cofy-x/axern-sdk 0.6.2 → 0.8.0
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.
- package/README.md +28 -76
- package/dist/client/index.d.ts +49 -11
- package/dist/client/index.js +207 -58
- package/dist/config/index.d.ts +0 -1
- package/dist/config/index.js +2 -4
- package/dist/generated/proto.js +0 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -2
- package/dist/node/archive.js +1 -1
- package/dist/node/attached_process.d.ts +1 -2
- package/dist/node/attached_process.js +14 -28
- package/dist/node/capabilities.js +1 -1
- package/dist/node/client.d.ts +4 -6
- package/dist/node/client.js +2 -9
- package/dist/node/computer_use.js +1 -1
- package/dist/node/context.d.ts +1 -1
- package/dist/node/context.js +1 -1
- package/dist/node/exec.d.ts +1 -3
- package/dist/node/exec.js +1 -44
- package/dist/node/files.js +1 -1
- package/dist/node/process.d.ts +11 -6
- package/dist/node/process.js +45 -6
- package/dist/proto/axern/control/admin/v1/access.proto +12 -5
- package/dist/proto/axern/control/admin/v1/audit.proto +14 -16
- package/dist/proto/axern/control/admin/v1/node.proto +23 -67
- package/dist/proto/axern/control/admin/v1/reliability.proto +11 -78
- package/dist/proto/axern/control/capability/v1/capability.proto +34 -94
- package/dist/proto/axern/control/common/v1/common.proto +41 -98
- package/dist/proto/axern/control/environment/v1/environment.proto +42 -26
- package/dist/proto/axern/control/identity/v1/identity.proto +1 -1
- package/dist/proto/axern/control/namespace/v1/namespace.proto +2 -3
- package/dist/proto/axern/control/quota/v1/quota.proto +22 -32
- package/dist/proto/axern/control/run/v1/run.proto +25 -22
- package/dist/proto/axern/control/secret/v1/secret.proto +1 -3
- package/dist/proto/axern/control/tunnel/v1/tunnel.proto +20 -29
- package/dist/proto/axern/node/sandbox/v1/node.proto +106 -375
- package/dist/sandbox/index.d.ts +5 -11
- package/dist/sandbox/index.js +24 -25
- package/dist/sandbox/lifecycle.d.ts +1 -1
- package/dist/sandbox/lifecycle.js +55 -17
- package/dist/tunnel/connector.js +1 -1
- package/dist/tunnel/control.d.ts +0 -1
- package/dist/tunnel/control.js +0 -1
- package/dist/tunnel/runtime.d.ts +0 -1
- package/dist/tunnel/runtime.js +0 -1
- package/dist/tunnel/types.d.ts +0 -1
- package/dist/types.d.ts +4 -24
- package/dist/types.js +1 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/proto/axern/control/admin/v1/allocation_lifecycle.proto +0 -91
- package/dist/proto/axern/control/admin/v1/service.proto +0 -18
- package/dist/proto/axern/control/admin/v1/storage.proto +0 -81
- package/dist/proto/axern/control/agentprofile/v1/agent_profile.proto +0 -169
- package/dist/proto/axern/control/catalog/v1/catalog.proto +0 -127
- package/dist/proto/axern/control/function/v1/function.proto +0 -130
- package/dist/proto/axern/control/function/v1/function_types.proto +0 -233
- package/dist/proto/axern/control/gateway/v1/gateway.proto +0 -92
- package/dist/proto/axern/control/node/v1/node_control.proto +0 -443
- package/dist/proto/axern/control/rollout/v1/rollout.proto +0 -344
- package/dist/proto/axern/control/service/v1/service.proto +0 -96
- package/dist/proto/axern/control/service/v1/service_event.proto +0 -47
- package/dist/proto/axern/control/service/v1/service_replica.proto +0 -78
- package/dist/proto/axern/control/service/v1/service_types.proto +0 -155
- package/dist/proto/axern/control/storage/v1/storage.proto +0 -107
- package/dist/proto/axern/control/storage/v1/storage_types.proto +0 -125
- package/dist/proto/axern/data/artifact/v1/artifact.proto +0 -19
package/README.md
CHANGED
|
@@ -28,18 +28,14 @@ pnpm install
|
|
|
28
28
|
make sdk-typescript-verify
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
The SDK dynamically loads Axern proto definitions from `sdk/proto`. Set
|
|
32
|
-
`AXERN_PROTO_ROOT` when running from a different layout.
|
|
31
|
+
The SDK dynamically loads Axern proto definitions from `sdk/proto`. Set `AXERN_PROTO_ROOT` when running from a different layout.
|
|
33
32
|
|
|
34
33
|
## Basic Usage
|
|
35
34
|
|
|
36
35
|
```ts
|
|
37
36
|
import { AxernClient, Sandbox } from "@cofy-x/axern-sdk";
|
|
38
37
|
|
|
39
|
-
const client = AxernClient.fromContext(
|
|
40
|
-
process.env.AXERN_CONFIG ?? `${process.env.HOME}/.config/axern/config.json`,
|
|
41
|
-
process.env.AXERN_CONTEXT,
|
|
42
|
-
);
|
|
38
|
+
const client = AxernClient.fromContext(process.env.AXERN_CONFIG ?? `${process.env.HOME}/.config/axern/config.json`, process.env.AXERN_CONTEXT);
|
|
43
39
|
|
|
44
40
|
const sandbox = await new Sandbox({
|
|
45
41
|
client,
|
|
@@ -70,9 +66,7 @@ try {
|
|
|
70
66
|
|
|
71
67
|
## Network Policies
|
|
72
68
|
|
|
73
|
-
Omitting `networkPolicy`
|
|
74
|
-
fail-closed; `denyDns` only refuses matching traditional UDP/TCP DNS queries and
|
|
75
|
-
does not block direct IP traffic, DoH, DoT, or already-resolved addresses.
|
|
69
|
+
Omitting `networkPolicy` requests unrestricted networking. Strict policies are fail-closed; `denyDns` only refuses matching traditional UDP/TCP DNS queries and does not block direct IP traffic, DoH, DoT, or already-resolved addresses.
|
|
76
70
|
|
|
77
71
|
```ts
|
|
78
72
|
import { NetworkPolicy, Sandbox } from "@cofy-x/axern-sdk";
|
|
@@ -80,89 +74,47 @@ import { NetworkPolicy, Sandbox } from "@cofy-x/axern-sdk";
|
|
|
80
74
|
const sandbox = await new Sandbox({
|
|
81
75
|
client,
|
|
82
76
|
image: "docker.io/library/python:3.12-slim",
|
|
83
|
-
networkPolicy: NetworkPolicy.denyDns(
|
|
84
|
-
"github.com",
|
|
85
|
-
"*.github.com",
|
|
86
|
-
"githubusercontent.com",
|
|
87
|
-
"*.githubusercontent.com",
|
|
88
|
-
),
|
|
77
|
+
networkPolicy: NetworkPolicy.denyDns("github.com", "*.github.com", "githubusercontent.com", "*.githubusercontent.com"),
|
|
89
78
|
}).start();
|
|
90
79
|
```
|
|
91
80
|
|
|
92
|
-
`NetworkPolicy.allowDomains("example.com", "*.example.com")` allows only
|
|
93
|
-
strict HTTP/HTTPS destinations validated by DNS plus HTTP Host or TLS SNI.
|
|
94
|
-
`NetworkPolicy.strict({ cidrRules: [...] })` adds explicit TCP/UDP CIDR and port
|
|
95
|
-
grants; `NetworkPolicy.denyAll()` allows no egress.
|
|
81
|
+
`NetworkPolicy.allowDomains("example.com", "*.example.com")` allows only strict HTTP/HTTPS destinations validated by DNS plus HTTP Host or TLS SNI. `NetworkPolicy.strict({ cidrRules: [...] })` adds explicit TCP/UDP CIDR and port grants; `NetworkPolicy.denyAll()` allows no egress.
|
|
96
82
|
|
|
97
|
-
|
|
98
|
-
Nydus refs use the same image field. When `mounts` is omitted, the SDK requests
|
|
99
|
-
`/workspace -> /workspace`; pass `mounts: []` for no shared paths. Use
|
|
100
|
-
`new Sandbox({ image })` when the image should be the sandbox rootfs with normal
|
|
101
|
-
files, exec, process, tunnel, and lifecycle APIs; image-backed processes are
|
|
102
|
-
temporary side processes attached to an existing sandbox.
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
import { workspaceMount } from "@cofy-x/axern-sdk";
|
|
106
|
-
|
|
107
|
-
const result = await sandbox.execImage("ghcr.io/cofy-x/agent:latest", "tool run", {
|
|
108
|
-
check: true,
|
|
109
|
-
mounts: [workspaceMount("/workspace")],
|
|
110
|
-
});
|
|
111
|
-
console.log(result.stdoutText());
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
`AxernClient` requires an explicit endpoint. Use `AxernClient.fromContext()` in
|
|
115
|
-
interactive examples or `AxernClient.fromEnv()` in environment-driven
|
|
116
|
-
automation. Neither the client constructor nor `Sandbox` silently reads the
|
|
117
|
-
user directory.
|
|
83
|
+
`AxernClient` requires an explicit endpoint. Use `AxernClient.fromContext()` in interactive examples or `AxernClient.fromEnv()` in environment-driven automation. Neither the client constructor nor `Sandbox` silently reads the user directory.
|
|
118
84
|
|
|
119
85
|
## Configuration
|
|
120
86
|
|
|
121
|
-
`Sandbox` requires exactly one source: `templateId`, `image`, or
|
|
122
|
-
`environmentId`.
|
|
87
|
+
`Sandbox` requires exactly one source: `templateId`, `image`, or `environmentId`.
|
|
123
88
|
|
|
124
89
|
Common options:
|
|
125
90
|
|
|
126
91
|
- `namespace`: control-plane namespace, default `default`
|
|
127
92
|
- `client`: explicit `AxernClient` shared by the sandbox
|
|
128
93
|
- `argv`, `env`, `cwd`: initial sandbox process configuration
|
|
129
|
-
- `
|
|
130
|
-
- `
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
- `limitCpu`, `limitMemory`, `limitEphemeralStorage`: runtime hard limits;
|
|
134
|
-
numeric CPU values are cores and numeric memory/storage values are bytes
|
|
135
|
-
- `readyTimeoutMs`: service replica readiness timeout
|
|
94
|
+
- `requestCpu`, `requestMemory`, `requestEphemeralStorage`: scheduler resource requests such as `500m`, `512MiB`, and `1GiB`; numeric CPU values are cores and numeric memory/storage values are bytes
|
|
95
|
+
- `limitCpu`, `limitMemory`, `limitEphemeralStorage`: runtime hard limits; numeric CPU values are cores and numeric memory/storage values are bytes
|
|
96
|
+
- `readyTimeoutMs`: Run allocation startup timeout
|
|
97
|
+
- `declaredOutputs`: bounded files or directory-as-tar outputs sealed before runtime cleanup
|
|
136
98
|
|
|
137
99
|
Tunnel options:
|
|
138
100
|
|
|
139
|
-
- `tunnel.upstream`: local TCP address reached by the SDK connector, for
|
|
140
|
-
example `127.0.0.1:8080`
|
|
101
|
+
- `tunnel.upstream`: local TCP address reached by the SDK connector, for example `127.0.0.1:8080`
|
|
141
102
|
- `tunnel.proxyPort`: sandbox-local port bound by Axern, default `8786`
|
|
142
103
|
- `tunnel.ttlSeconds`: control-plane tunnel session TTL, renewed by the SDK
|
|
143
104
|
|
|
144
|
-
Tunnel traffic reuses the client gateway endpoint, mTLS identity, server name,
|
|
145
|
-
and proxy policy.
|
|
105
|
+
Tunnel traffic reuses the client gateway endpoint, mTLS identity, server name, and proxy policy.
|
|
146
106
|
|
|
147
|
-
After `start()`, use `sandbox.metadata.tunnel?.boundAddr` from inside the
|
|
148
|
-
sandbox, for example `http://127.0.0.1:8786`.
|
|
107
|
+
After `start()`, use `sandbox.metadata.tunnel?.boundAddr` from inside the sandbox, for example `http://127.0.0.1:8786`.
|
|
149
108
|
|
|
150
109
|
## Sandbox API
|
|
151
110
|
|
|
152
111
|
- Lifecycle: `start()`, `close()`, `state`, `metadata`
|
|
153
112
|
- Execution: `exec(command, options)`, `process(command, options)`
|
|
154
|
-
- Agent sandbox: `capabilityStatus`, `computerUseStatus`,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
-
|
|
158
|
-
|
|
159
|
-
- Directories: `uploadDir(localPath, remotePath)`,
|
|
160
|
-
`downloadDir(remotePath, localPath)`
|
|
161
|
-
- Tunnel: `new Sandbox({ tunnel: { upstream, proxyPort } })`,
|
|
162
|
-
`metadata.tunnel`
|
|
163
|
-
- Errors: `AxernRpcError`, `SandboxExecError`, `SandboxStateError`,
|
|
164
|
-
`SandboxValidationError`, `isNotFound`, `isPermissionDenied`, `isTimeout`,
|
|
165
|
-
`rpcCode`
|
|
113
|
+
- Agent sandbox: `capabilityStatus`, `computerUseStatus`, `computerUseScreenshot`, `computerUseDisplay`, `computerUseMouse`, `computerUseKeyboard`
|
|
114
|
+
- Files: `readFile`, `readText`, `writeFile`, `writeText`, `stat`, `listDir`, `exists`, `mkdir`, `remove`, `copy`, `move`, `chmod`, `touch`
|
|
115
|
+
- Directories: `uploadDir(localPath, remotePath)`, `downloadDir(remotePath, localPath)`
|
|
116
|
+
- Tunnel: `new Sandbox({ tunnel: { upstream, proxyPort } })`, `metadata.tunnel`
|
|
117
|
+
- Errors: `AxernRpcError`, `SandboxExecError`, `SandboxStateError`, `SandboxValidationError`, `isNotFound`, `isPermissionDenied`, `isTimeout`, `rpcCode`
|
|
166
118
|
|
|
167
119
|
## Local Smoke
|
|
168
120
|
|
|
@@ -173,9 +125,7 @@ pnpm --filter @cofy-x/axern-sdk run smoke:local
|
|
|
173
125
|
pnpm --filter @cofy-x/axern-sdk run smoke:tunnel
|
|
174
126
|
```
|
|
175
127
|
|
|
176
|
-
The smoke loads `deploy/local/state/compose/axern.env` when present. It uses the
|
|
177
|
-
`python311` template by default; set `AXERN_TS_SMOKE_IMAGE` to verify an image
|
|
178
|
-
source instead.
|
|
128
|
+
The smoke loads `deploy/local/state/compose/axern.env` when present. It uses the `python311` template by default; set `AXERN_TS_SMOKE_IMAGE` to verify an image source instead.
|
|
179
129
|
|
|
180
130
|
Check package contents with:
|
|
181
131
|
|
|
@@ -185,12 +135,14 @@ pnpm --filter @cofy-x/axern-sdk run pack:dry-run
|
|
|
185
135
|
|
|
186
136
|
## Proto Boundary
|
|
187
137
|
|
|
188
|
-
The SDK loads protobuf definitions through `@grpc/proto-loader`. Dynamic proto
|
|
189
|
-
access remains isolated under `src/generated`; public callers depend only on
|
|
190
|
-
the stable TypeScript DTOs and error types.
|
|
138
|
+
The SDK loads protobuf definitions through `@grpc/proto-loader`. Dynamic proto access remains isolated under `src/generated`; public callers depend only on the stable TypeScript DTOs and error types.
|
|
191
139
|
|
|
192
140
|
## Scope
|
|
193
141
|
|
|
194
|
-
This SDK is Node.js-first.
|
|
195
|
-
|
|
196
|
-
|
|
142
|
+
This SDK is Node.js-first. Browser automation runs as caller-owned workload software through process and Computer Use operations; generated TypeScript proto stubs and full control-plane administration APIs remain outside its public contract.
|
|
143
|
+
|
|
144
|
+
## Declared And Stream Output
|
|
145
|
+
|
|
146
|
+
Pass `declaredOutputs` when creating a Run or Sandbox, then use `getSealedOutputManifest(runId)` and `downloadSealedOutput(runId, outputId, writable)` after the Run becomes terminal. Full downloads verify size and SHA-256 while respecting the destination stream's backpressure. Declared output is Node-local for 15 minutes after cleanup starts: it survives axnoded restart but not Node-disk loss. Limits are 16 paths, 64 MiB per file, 256 MiB per tar, and 256 MiB total. It is not a persistent workspace or object store.
|
|
147
|
+
|
|
148
|
+
Attached Process queues at most 64 unread events, pauses the gRPC stream at the bound, and resumes below the low-water mark. `write`, `closeStdin`, signal, and resize promises resolve only after the gRPC write callback. Closing an attached process attempts `TERM`; durable workload termination remains Run cancellation. `Sandbox.close()` reports cleanup failures and does not wait for a terminal Run, so call `waitRun()` when terminal confirmation is required.
|
package/dist/client/index.d.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
import * as grpc from "@grpc/grpc-js";
|
|
7
|
-
import {
|
|
7
|
+
import type { Writable } from "node:stream";
|
|
8
|
+
import { AllocationClient } from "../node/client.js";
|
|
8
9
|
import type { ResourceQuantity } from "../resources.js";
|
|
9
10
|
import type { NetworkPolicy } from "../network-policy.js";
|
|
10
11
|
import { TunnelControlClient } from "../tunnel/control.js";
|
|
11
|
-
import type { VolumeMount } from "../types.js";
|
|
12
12
|
import type { GatewayTransportOptions } from "../tunnel/relay.js";
|
|
13
13
|
export interface AxernClientOptions {
|
|
14
14
|
endpoint: string;
|
|
@@ -27,24 +27,51 @@ export interface CreateEnvironmentOptions {
|
|
|
27
27
|
rootfsReadonly?: boolean;
|
|
28
28
|
labels?: Record<string, string>;
|
|
29
29
|
}
|
|
30
|
-
export interface
|
|
30
|
+
export interface CreateRunOptions {
|
|
31
31
|
namespace?: string;
|
|
32
32
|
environmentId: string;
|
|
33
33
|
argv?: string[];
|
|
34
34
|
env?: Record<string, string>;
|
|
35
35
|
cwd?: string;
|
|
36
|
-
runtimeClass?: string;
|
|
37
36
|
networkPolicy?: NetworkPolicy;
|
|
38
37
|
extensionCapabilities?: readonly ExtensionCapability[];
|
|
39
|
-
volumes?: readonly VolumeMount[];
|
|
40
38
|
requestCpu?: ResourceQuantity;
|
|
41
39
|
requestMemory?: ResourceQuantity;
|
|
42
40
|
requestEphemeralStorage?: ResourceQuantity;
|
|
43
41
|
limitCpu?: ResourceQuantity;
|
|
44
42
|
limitMemory?: ResourceQuantity;
|
|
45
43
|
limitEphemeralStorage?: ResourceQuantity;
|
|
44
|
+
declaredOutputs?: readonly DeclaredOutput[];
|
|
46
45
|
labels?: Record<string, string>;
|
|
47
46
|
}
|
|
47
|
+
export type DeclaredOutputFormat = "file" | "tar";
|
|
48
|
+
export interface DeclaredOutput {
|
|
49
|
+
path: string;
|
|
50
|
+
format: DeclaredOutputFormat;
|
|
51
|
+
mediaType?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface SealedOutput {
|
|
54
|
+
outputId: string;
|
|
55
|
+
path: string;
|
|
56
|
+
sizeBytes: number;
|
|
57
|
+
sha256: string;
|
|
58
|
+
mediaType: string;
|
|
59
|
+
format?: DeclaredOutputFormat;
|
|
60
|
+
status: "available" | "missing" | "rejected" | "capture_failed" | "node_unavailable" | "unspecified";
|
|
61
|
+
reason: string;
|
|
62
|
+
sealedAt?: Record<string, unknown>;
|
|
63
|
+
expiresAt?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
export interface ListOptions {
|
|
66
|
+
namespace?: string;
|
|
67
|
+
labels?: Record<string, string>;
|
|
68
|
+
cursor?: string;
|
|
69
|
+
pageSize?: number;
|
|
70
|
+
}
|
|
71
|
+
export interface ListResult<T> {
|
|
72
|
+
items: T[];
|
|
73
|
+
nextCursor: string;
|
|
74
|
+
}
|
|
48
75
|
export interface ExtensionCapability {
|
|
49
76
|
name: string;
|
|
50
77
|
value?: string;
|
|
@@ -53,13 +80,16 @@ export interface ReadRunOutputOptions {
|
|
|
53
80
|
cursor?: string;
|
|
54
81
|
follow?: boolean;
|
|
55
82
|
}
|
|
83
|
+
export interface WatchRunOptions {
|
|
84
|
+
afterVersion?: number;
|
|
85
|
+
signal?: AbortSignal;
|
|
86
|
+
}
|
|
56
87
|
export declare class AxernClient {
|
|
57
88
|
readonly endpoint: string;
|
|
58
89
|
private readonly credentials;
|
|
59
90
|
private readonly controlOptions;
|
|
60
91
|
private readonly environmentControl;
|
|
61
92
|
private readonly runControl;
|
|
62
|
-
private readonly serviceControl;
|
|
63
93
|
private readonly tunnelControl;
|
|
64
94
|
private readonly gatewayTransport;
|
|
65
95
|
constructor(options: AxernClientOptions);
|
|
@@ -68,12 +98,20 @@ export declare class AxernClient {
|
|
|
68
98
|
close(): void;
|
|
69
99
|
createEnvironment(options: CreateEnvironmentOptions): Promise<Record<string, unknown>>;
|
|
70
100
|
deleteEnvironment(environmentId: string): Promise<void>;
|
|
71
|
-
|
|
101
|
+
getEnvironment(environmentId: string): Promise<Record<string, unknown>>;
|
|
102
|
+
listEnvironments(options?: ListOptions): Promise<ListResult<Record<string, unknown>>>;
|
|
103
|
+
createRun(options: CreateRunOptions): Promise<Record<string, unknown>>;
|
|
104
|
+
getRun(runId: string): Promise<Record<string, unknown>>;
|
|
105
|
+
listRuns(options?: ListOptions & {
|
|
106
|
+
statuses?: number[];
|
|
107
|
+
}): Promise<ListResult<Record<string, unknown>>>;
|
|
108
|
+
waitRun(runId: string, timeoutMs?: number): Promise<Record<string, unknown>>;
|
|
109
|
+
cancelRun(runId: string): Promise<void>;
|
|
110
|
+
watchRun(runId: string, options?: WatchRunOptions): AsyncGenerator<Record<string, unknown>>;
|
|
72
111
|
readRunOutput(runId: string, options?: ReadRunOutputOptions): AsyncGenerator<Record<string, unknown>>;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
nodeSandbox(allocationId: string): NodeSandboxClient;
|
|
112
|
+
getSealedOutputManifest(runId: string): Promise<SealedOutput[]>;
|
|
113
|
+
downloadSealedOutput(runId: string, outputId: string, destination: Writable): Promise<SealedOutput>;
|
|
114
|
+
allocation(allocationId: string): AllocationClient;
|
|
77
115
|
tunnelClient(): TunnelControlClient;
|
|
78
116
|
tunnelTransport(): GatewayTransportOptions;
|
|
79
117
|
}
|
package/dist/client/index.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
import * as grpc from "@grpc/grpc-js";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
7
8
|
import { readFileSync } from "node:fs";
|
|
8
9
|
import { loadAxernContext, loadAxernEnv, normalizeProxyMode } from "../config/index.js";
|
|
9
|
-
import { mapRpcError } from "../errors/index.js";
|
|
10
|
+
import { SandboxStateError, SandboxTimeoutError, mapRpcError } from "../errors/index.js";
|
|
10
11
|
import { serviceConstructor, unary } from "../generated/proto.js";
|
|
11
|
-
import {
|
|
12
|
+
import { AllocationClient } from "../node/client.js";
|
|
12
13
|
import { buildResourceSpec } from "../resources.js";
|
|
13
14
|
import { TunnelControlClient } from "../tunnel/control.js";
|
|
14
15
|
import { required } from "../validation.js";
|
|
@@ -18,7 +19,6 @@ export class AxernClient {
|
|
|
18
19
|
controlOptions;
|
|
19
20
|
environmentControl;
|
|
20
21
|
runControl;
|
|
21
|
-
serviceControl;
|
|
22
22
|
tunnelControl;
|
|
23
23
|
gatewayTransport;
|
|
24
24
|
constructor(options) {
|
|
@@ -57,11 +57,9 @@ export class AxernClient {
|
|
|
57
57
|
"EnvironmentControl",
|
|
58
58
|
]);
|
|
59
59
|
const RunControl = serviceConstructor(["axern", "control", "run", "v1", "RunControl"]);
|
|
60
|
-
const ServiceControl = serviceConstructor(["axern", "control", "service", "v1", "ServiceControl"]);
|
|
61
60
|
const TunnelControl = serviceConstructor(["axern", "control", "tunnel", "v1", "TunnelControl"]);
|
|
62
61
|
this.environmentControl = new EnvironmentControl(this.endpoint, this.credentials, this.controlOptions);
|
|
63
62
|
this.runControl = new RunControl(this.endpoint, this.credentials, this.controlOptions);
|
|
64
|
-
this.serviceControl = new ServiceControl(this.endpoint, this.credentials, this.controlOptions);
|
|
65
63
|
this.tunnelControl = new TunnelControl(this.endpoint, this.credentials, this.controlOptions);
|
|
66
64
|
}
|
|
67
65
|
static fromEnv(overrides = {}) {
|
|
@@ -82,7 +80,6 @@ export class AxernClient {
|
|
|
82
80
|
close() {
|
|
83
81
|
this.environmentControl.close();
|
|
84
82
|
this.runControl.close();
|
|
85
|
-
this.serviceControl.close();
|
|
86
83
|
this.tunnelControl.close();
|
|
87
84
|
}
|
|
88
85
|
async createEnvironment(options) {
|
|
@@ -118,17 +115,118 @@ export class AxernClient {
|
|
|
118
115
|
throw mapRpcError(error, "delete environment");
|
|
119
116
|
}
|
|
120
117
|
}
|
|
121
|
-
async
|
|
118
|
+
async getEnvironment(environmentId) {
|
|
119
|
+
try {
|
|
120
|
+
const response = await unary(this.environmentControl, "GetEnvironment", { environment_id: required("environmentId", environmentId) });
|
|
121
|
+
return response.environment;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw mapRpcError(error, "get environment");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async listEnvironments(options = {}) {
|
|
128
|
+
try {
|
|
129
|
+
const response = await unary(this.environmentControl, "ListEnvironments", { filter: { namespace: options.namespace ?? "", labels: options.labels ?? {}, cursor: options.cursor ?? "", page_size: options.pageSize ?? 0 } });
|
|
130
|
+
return { items: response.environments ?? [], nextCursor: response.next_cursor ?? "" };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
throw mapRpcError(error, "list environments");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async createRun(options) {
|
|
137
|
+
const resources = buildResourceSpec(options);
|
|
138
|
+
try {
|
|
139
|
+
const response = await unary(this.runControl, "CreateRun", {
|
|
140
|
+
namespace: options.namespace ?? "default",
|
|
141
|
+
environment_id: required("environmentId", options.environmentId),
|
|
142
|
+
config: {
|
|
143
|
+
argv: options.argv ?? [],
|
|
144
|
+
env: options.env ?? {},
|
|
145
|
+
cwd: options.cwd ?? "",
|
|
146
|
+
...(options.networkPolicy === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { network: { egress_policy: options.networkPolicy.toWire() } }),
|
|
149
|
+
extension_capability_requirements: (options.extensionCapabilities ?? []).map((capability) => ({
|
|
150
|
+
capability: { name: capability.name, value: capability.value ?? "" },
|
|
151
|
+
})),
|
|
152
|
+
declared_outputs: (options.declaredOutputs ?? []).map((output) => ({
|
|
153
|
+
path: output.path,
|
|
154
|
+
format: output.format === "file" ? 1 : 2,
|
|
155
|
+
media_type: output.mediaType ?? "",
|
|
156
|
+
})),
|
|
157
|
+
resources,
|
|
158
|
+
},
|
|
159
|
+
labels: options.labels ?? {},
|
|
160
|
+
});
|
|
161
|
+
return response.run;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw mapRpcError(error, "create run");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async getRun(runId) {
|
|
168
|
+
try {
|
|
169
|
+
const response = await unary(this.runControl, "GetRun", { run_id: required("runId", runId) });
|
|
170
|
+
return response.run;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
throw mapRpcError(error, "get run");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async listRuns(options = {}) {
|
|
177
|
+
try {
|
|
178
|
+
const response = await unary(this.runControl, "ListRuns", { filter: { namespace: options.namespace ?? "", labels: options.labels ?? {}, statuses: options.statuses ?? [], cursor: options.cursor ?? "", page_size: options.pageSize ?? 0 } });
|
|
179
|
+
return { items: response.runs ?? [], nextCursor: response.next_cursor ?? "" };
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
throw mapRpcError(error, "list runs");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async waitRun(runId, timeoutMs) {
|
|
186
|
+
const initial = await this.getRun(runId);
|
|
187
|
+
if (terminalRun(initial))
|
|
188
|
+
return initial;
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
const timer = timeoutMs === undefined ? undefined : setTimeout(() => controller.abort(), timeoutMs);
|
|
191
|
+
try {
|
|
192
|
+
for await (const run of this.watchRun(runId, { afterVersion: Number(initial.version ?? 0), signal: controller.signal })) {
|
|
193
|
+
if (terminalRun(run))
|
|
194
|
+
return run;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (timer !== undefined)
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
if (controller.signal.aborted)
|
|
202
|
+
throw new SandboxTimeoutError(`run ${runId} wait timed out`);
|
|
203
|
+
throw new SandboxStateError(`run ${runId} watch ended before a terminal state`);
|
|
204
|
+
}
|
|
205
|
+
async cancelRun(runId) {
|
|
206
|
+
try {
|
|
207
|
+
await unary(this.runControl, "CancelRun", { run_id: required("runId", runId) });
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
throw mapRpcError(error, "cancel run");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async *watchRun(runId, options = {}) {
|
|
214
|
+
const afterVersion = options.afterVersion ?? 0;
|
|
215
|
+
const signal = options.signal;
|
|
122
216
|
if (afterVersion < 0) {
|
|
123
217
|
throw new Error("afterVersion must be non-negative");
|
|
124
218
|
}
|
|
125
219
|
let version = afterVersion;
|
|
126
220
|
let retryDelayMs = 100;
|
|
127
221
|
for (;;) {
|
|
222
|
+
if (signal?.aborted)
|
|
223
|
+
return;
|
|
128
224
|
const stream = serverStream(this.runControl, "WatchRun", {
|
|
129
225
|
run_id: required("runId", runId),
|
|
130
226
|
after_version: version,
|
|
131
227
|
});
|
|
228
|
+
const cancel = () => stream.cancel();
|
|
229
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
132
230
|
try {
|
|
133
231
|
for await (const response of stream) {
|
|
134
232
|
const run = response.run;
|
|
@@ -144,14 +242,21 @@ export class AxernClient {
|
|
|
144
242
|
return;
|
|
145
243
|
}
|
|
146
244
|
catch (error) {
|
|
245
|
+
if (signal?.aborted)
|
|
246
|
+
return;
|
|
147
247
|
if (!transientReadError(error))
|
|
148
248
|
throw mapRpcError(error, "watch run");
|
|
149
249
|
}
|
|
150
|
-
|
|
250
|
+
finally {
|
|
251
|
+
signal?.removeEventListener("abort", cancel);
|
|
252
|
+
}
|
|
253
|
+
await sleep(retryDelayMs, signal);
|
|
151
254
|
retryDelayMs = Math.min(retryDelayMs * 2, 2_000);
|
|
152
255
|
}
|
|
153
256
|
}
|
|
154
257
|
async *readRunOutput(runId, options = {}) {
|
|
258
|
+
// Output is Allocation-local and may be unavailable after cleanup; callers
|
|
259
|
+
// that need durable bytes must consume and persist them before then.
|
|
155
260
|
const response = await unary(this.runControl, "GetRun", { run_id: required("runId", runId) });
|
|
156
261
|
const allocationId = String(response.run?.allocation_id ?? "");
|
|
157
262
|
if (allocationId === "")
|
|
@@ -195,57 +300,65 @@ export class AxernClient {
|
|
|
195
300
|
retryDelayMs = Math.min(retryDelayMs * 2, 2_000);
|
|
196
301
|
}
|
|
197
302
|
}
|
|
198
|
-
async
|
|
199
|
-
const
|
|
303
|
+
async getSealedOutputManifest(runId) {
|
|
304
|
+
const run = await this.getRun(runId);
|
|
305
|
+
const allocationId = String(run.allocation_id ?? "");
|
|
306
|
+
if (allocationId === "")
|
|
307
|
+
throw new Error(`run ${runId} has no allocation`);
|
|
308
|
+
const NodeSandbox = serviceConstructor(["axern", "node", "sandbox", "v1", "NodeSandbox"]);
|
|
309
|
+
const node = new NodeSandbox(this.endpoint, this.credentials, this.controlOptions);
|
|
200
310
|
try {
|
|
201
|
-
const response = await unary(
|
|
202
|
-
|
|
203
|
-
environment_id: required("environmentId", options.environmentId),
|
|
204
|
-
replicas: 1,
|
|
205
|
-
config: {
|
|
206
|
-
argv: options.argv ?? [],
|
|
207
|
-
env: options.env ?? {},
|
|
208
|
-
cwd: options.cwd ?? "",
|
|
209
|
-
runtime_class: options.runtimeClass ?? "",
|
|
210
|
-
...(options.networkPolicy === undefined
|
|
211
|
-
? {}
|
|
212
|
-
: { network: { egress_policy: options.networkPolicy.toWire() } }),
|
|
213
|
-
extension_capability_requirements: (options.extensionCapabilities ?? []).map((capability) => ({
|
|
214
|
-
capability: { name: capability.name, value: capability.value ?? "" },
|
|
215
|
-
})),
|
|
216
|
-
volume_mounts: serviceVolumeMounts(options.volumes),
|
|
217
|
-
resources,
|
|
218
|
-
},
|
|
219
|
-
labels: options.labels ?? {},
|
|
220
|
-
});
|
|
221
|
-
return response.service;
|
|
311
|
+
const response = await unary(node, "GetSealedOutputManifest", { allocation_id: allocationId });
|
|
312
|
+
return (response.outputs ?? []).map(sealedOutput);
|
|
222
313
|
}
|
|
223
314
|
catch (error) {
|
|
224
|
-
throw mapRpcError(error, "
|
|
315
|
+
throw mapRpcError(error, "get sealed output manifest", allocationId);
|
|
225
316
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
await unary(this.serviceControl, "DeleteService", { service_id: required("serviceId", serviceId) });
|
|
230
|
-
}
|
|
231
|
-
catch (error) {
|
|
232
|
-
throw mapRpcError(error, "delete service");
|
|
317
|
+
finally {
|
|
318
|
+
node.close();
|
|
233
319
|
}
|
|
234
320
|
}
|
|
235
|
-
async
|
|
321
|
+
async downloadSealedOutput(runId, outputId, destination) {
|
|
322
|
+
const outputs = await this.getSealedOutputManifest(runId);
|
|
323
|
+
const selected = outputs.find((output) => output.outputId === outputId);
|
|
324
|
+
if (selected === undefined || selected.status !== "available") {
|
|
325
|
+
throw new Error(`sealed output ${outputId} is not available`);
|
|
326
|
+
}
|
|
327
|
+
const run = await this.getRun(runId);
|
|
328
|
+
const allocationId = String(run.allocation_id ?? "");
|
|
329
|
+
const NodeSandbox = serviceConstructor(["axern", "node", "sandbox", "v1", "NodeSandbox"]);
|
|
330
|
+
const node = new NodeSandbox(this.endpoint, this.credentials, this.controlOptions);
|
|
331
|
+
const stream = serverStream(node, "DownloadSealedOutput", { allocation_id: allocationId, output_id: outputId, offset: "0" });
|
|
332
|
+
const digest = createHash("sha256");
|
|
333
|
+
let offset = 0;
|
|
236
334
|
try {
|
|
237
|
-
const response
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
335
|
+
for await (const response of stream) {
|
|
336
|
+
const data = Buffer.from(response.data ?? []);
|
|
337
|
+
const nextOffset = Number(response.next_offset ?? offset + data.length);
|
|
338
|
+
if (nextOffset !== offset + data.length)
|
|
339
|
+
throw new Error("sealed output returned a non-contiguous offset");
|
|
340
|
+
digest.update(data);
|
|
341
|
+
await writeChunk(destination, data);
|
|
342
|
+
offset = nextOffset;
|
|
343
|
+
}
|
|
242
344
|
}
|
|
243
345
|
catch (error) {
|
|
244
|
-
|
|
346
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
347
|
+
throw mapRpcError(error, "download sealed output", allocationId);
|
|
348
|
+
}
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
node.close();
|
|
245
353
|
}
|
|
354
|
+
if (offset !== selected.sizeBytes)
|
|
355
|
+
throw new Error("sealed output size does not match its manifest");
|
|
356
|
+
if (digest.digest("hex") !== selected.sha256)
|
|
357
|
+
throw new Error("sealed output digest does not match its manifest");
|
|
358
|
+
return selected;
|
|
246
359
|
}
|
|
247
|
-
|
|
248
|
-
return new
|
|
360
|
+
allocation(allocationId) {
|
|
361
|
+
return new AllocationClient({
|
|
249
362
|
allocationId: required("allocationId", allocationId),
|
|
250
363
|
target: this.endpoint,
|
|
251
364
|
credentials: this.credentials,
|
|
@@ -259,14 +372,6 @@ export class AxernClient {
|
|
|
259
372
|
return { ...this.gatewayTransport };
|
|
260
373
|
}
|
|
261
374
|
}
|
|
262
|
-
function serviceVolumeMounts(mounts) {
|
|
263
|
-
return (mounts ?? []).map((mount) => ({
|
|
264
|
-
name: mount.name,
|
|
265
|
-
target: mount.target,
|
|
266
|
-
readonly: mount.readonly ?? false,
|
|
267
|
-
options: [...(mount.options ?? [])],
|
|
268
|
-
}));
|
|
269
|
-
}
|
|
270
375
|
function serverStream(client, method, request) {
|
|
271
376
|
const fn = client[method];
|
|
272
377
|
return fn.call(client, request);
|
|
@@ -275,8 +380,52 @@ function transientReadError(error) {
|
|
|
275
380
|
const code = error.code;
|
|
276
381
|
return code === grpc.status.UNAVAILABLE || code === grpc.status.DEADLINE_EXCEEDED;
|
|
277
382
|
}
|
|
278
|
-
function
|
|
279
|
-
|
|
383
|
+
function terminalRun(run) {
|
|
384
|
+
const status = Number(run.status ?? 0);
|
|
385
|
+
return status === 4 || status === 5 || status === 6;
|
|
386
|
+
}
|
|
387
|
+
function sealedOutput(value) {
|
|
388
|
+
const formats = { 1: "file", 2: "tar" };
|
|
389
|
+
const statuses = ["unspecified", "available", "missing", "rejected", "capture_failed", "node_unavailable"];
|
|
390
|
+
return {
|
|
391
|
+
outputId: String(value.output_id ?? ""),
|
|
392
|
+
path: String(value.path ?? ""),
|
|
393
|
+
sizeBytes: Number(value.size_bytes ?? 0),
|
|
394
|
+
sha256: String(value.sha256 ?? ""),
|
|
395
|
+
mediaType: String(value.media_type ?? ""),
|
|
396
|
+
format: formats[Number(value.format ?? 0)],
|
|
397
|
+
status: statuses[Number(value.status ?? 0)] ?? "unspecified",
|
|
398
|
+
reason: String(value.reason ?? ""),
|
|
399
|
+
sealedAt: value.sealed_at,
|
|
400
|
+
expiresAt: value.expires_at,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function writeChunk(destination, chunk) {
|
|
404
|
+
if (destination.write(chunk))
|
|
405
|
+
return Promise.resolve();
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
const cleanup = () => {
|
|
408
|
+
destination.off("drain", drained);
|
|
409
|
+
destination.off("error", failed);
|
|
410
|
+
};
|
|
411
|
+
const drained = () => { cleanup(); resolve(); };
|
|
412
|
+
const failed = (error) => { cleanup(); reject(error); };
|
|
413
|
+
destination.once("drain", drained);
|
|
414
|
+
destination.once("error", failed);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function sleep(milliseconds, signal) {
|
|
418
|
+
if (signal?.aborted)
|
|
419
|
+
return Promise.resolve();
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
const complete = () => {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
signal?.removeEventListener("abort", complete);
|
|
424
|
+
resolve();
|
|
425
|
+
};
|
|
426
|
+
const timer = setTimeout(complete, milliseconds);
|
|
427
|
+
signal?.addEventListener("abort", complete, { once: true });
|
|
428
|
+
});
|
|
280
429
|
}
|
|
281
430
|
function controlCredentials(options) {
|
|
282
431
|
const configured = [options.tlsCaCert, options.tlsCert, options.tlsKey].filter((value) => value !== undefined && value !== "");
|