Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ const sb = await porter.sandboxes.create({
});
```

Volume contents can be browsed and read directly from the volume handle:

```typescript
for (const file of await volume.listdir('/checkpoints')) {
console.log(file.path, file.sizeBytes);
}

const config = await volume.readText('/checkpoints/config.json');

for await (const chunk of volume.stream('/checkpoints/model.safetensors')) {
process.stdout.write(chunk);
}
```

Inside a sandbox-enabled Porter cluster, the SDK connects to the in-cluster
sandbox API at `http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080`
automatically, with no configuration needed.
Expand Down Expand Up @@ -67,11 +81,13 @@ const sandboxes = await Promise.all(

## Layout

- `src/sandbox.ts` — rich `Sandbox` handle (hand-written ergonomic API)
- `src/porter.ts`, `src/sandboxes.ts`, `src/volumes.ts`, `src/healthz.ts`, `src/readyz.ts` — generated public `Porter` client and resource namespaces
- `src/_client.ts`, `src/_models.ts`, `src/enums.ts`, `src/_errors.ts`, `src/resources/` — generated from the sandbox-api OpenAPI spec. Do not edit by hand.
- `src/_baseClient.ts` — hand-written `fetch`-based HTTP transport (auth, retries, error mapping)
- `src/_config.ts`, `src/_retries.ts` — hand-written runtime (env-var resolution, retry/backoff)
Everything under `src/` is generated from the Porter Sandbox OpenAPI spec. Do
not edit it by hand - changes there are overwritten on the next release.

- `src/sandbox.ts`, `src/volume.ts` - `Sandbox` and `Volume` handles
- `src/porter.ts`, `src/sandboxes.ts`, `src/volumes.ts`, `src/healthz.ts`, `src/readyz.ts` - public `Porter` client and resource namespaces
- `src/_client.ts`, `src/_models.ts`, `src/enums.ts`, `src/_errors.ts`, `src/resources/` - low-level client, models, and errors
- `src/_baseClient.ts`, `src/_config.ts`, `src/_retries.ts` - HTTP transport, env-var resolution, retry/backoff

## Development

Expand All @@ -81,9 +97,3 @@ npm test
npm run typecheck
npm run build
```

To pull in a fresh generation from the sdk-gen workspace:

```bash
./scripts/sync-generated.sh /path/to/sdk-gen/out/typescript
```
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"smoke": "tsx scripts/smoke.ts",
"sync-generated": "./scripts/sync-generated.sh"
"smoke": "tsx scripts/smoke.ts"
},
"devDependencies": {
"@types/node": "^20.11.0",
Expand Down
67 changes: 0 additions & 67 deletions scripts/sync-generated.sh

This file was deleted.

78 changes: 69 additions & 9 deletions src/_baseClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Generated by oagen. DO NOT EDIT.


import { errorForStatus, SandboxError, SandboxTimeoutError } from './_errors.js';
import { resolveConfig, type Config, type ConfigInput } from './_config.js';
import { DEFAULT_MAX_RETRIES, shouldRetry, sleepForAttempt } from './_retries.js';
Expand All @@ -12,6 +15,7 @@ export interface RequestOptions {
method: string;
path: string;
query?: Record<string, unknown> | undefined;
headers?: Record<string, string | undefined> | undefined;
body?: unknown;
// Per-request timeout. `null` disables the timeout entirely, used for
// long-running calls like exec, where the API works for the full duration
Expand All @@ -22,9 +26,26 @@ export interface RequestOptions {
retry?: boolean | undefined;
}

/** A byte range within a file, read back from a response's Content-Range header. */
export interface ContentRange {
start: number;
end: number;
size: number;
}

/** Raw bytes from an octet-stream endpoint, with the metadata needed to read a file in pieces. */
export interface BinaryContent {
bytes: Uint8Array;
// Which bytes of the file arrived and how large the file is, set when the
// request asked for a range.
contentRange: ContentRange | null;
lastModified: Date | null;
}

// HTTP transport shared by the generated resource classes. Owns auth/header
// injection, the retry loop, and error mapping. Resource methods call
// `this.client.request<T>(...)`.
// `this.client.request<T>(...)`, or `requestBinary(...)` for the endpoints
// that answer with bytes rather than JSON.
export class BaseClient {
private readonly config: Config;
private readonly maxRetries: number;
Expand All @@ -41,8 +62,25 @@ export class BaseClient {
}

async request<T>(options: RequestOptions): Promise<T> {
const response = await this.send(options, 'application/json');
return (await decodeBody(response)) as T;
}

async requestBinary(options: RequestOptions): Promise<BinaryContent> {
const response = await this.send(options, 'application/octet-stream');
const buffer = await response.arrayBuffer();
return {
bytes: new Uint8Array(buffer),
contentRange: parseContentRange(response.headers.get('content-range')),
lastModified: parseHttpDate(response.headers.get('last-modified')),
};
}

// Runs the request through the retry loop and hands back the successful
// response with its body unread, so callers decode it as JSON or as bytes.
private async send(options: RequestOptions, accept: string): Promise<Response> {
const url = buildUrl(this.config.baseUrl, options.path, options.query);
const init = buildInit(this.config, options);
const init = buildInit(this.config, options, accept);
const timeoutMs = options.timeoutMs === undefined ? this.config.timeoutMs : options.timeoutMs;
const maxRetries = options.retry === false ? 0 : this.maxRetries;

Expand All @@ -64,9 +102,7 @@ export class BaseClient {
throw new SandboxError(`Network error: ${describeError(err)}`);
}

if (response.status >= 200 && response.status < 300) {
return (await decodeBody(response)) as T;
}
if (response.status >= 200 && response.status < 300) return response;

if (shouldRetry(response.status) && attempt < maxRetries) {
await sleepForAttempt(attempt);
Expand All @@ -81,8 +117,14 @@ export class BaseClient {
}
}

const buildUrl = (baseUrl: string, path: string, query: Record<string, unknown> | undefined): string => {
const url = new URL(path, `${baseUrl}/`);
export const buildUrl = (baseUrl: string, path: string, query: Record<string, unknown> | undefined): string => {
// Concatenate rather than `new URL(path, base)`: a leading-slash path is an
// absolute reference, so `new URL` would discard any path prefix on the base
// (e.g. the Porter gateway base `…/projects/1/clusters/2` that _config
// builds from the API token) and resolve to `https://host/v1/sandbox/run`.
// Joining the strings keeps the prefix, so the same client works whether it
// points straight at the in-cluster sandbox-api or through the Porter API.
const url = new URL(`${baseUrl.replace(/\/$/, '')}${path}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
Expand All @@ -96,14 +138,17 @@ const buildUrl = (baseUrl: string, path: string, query: Record<string, unknown>
return url.toString();
};

const buildInit = (config: Config, options: RequestOptions): RequestInit => {
const buildInit = (config: Config, options: RequestOptions, accept: string): RequestInit => {
const headers: Record<string, string> = {
'User-Agent': USER_AGENT,
Accept: 'application/json',
Accept: accept,
};
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
for (const [name, value] of Object.entries(options.headers ?? {})) {
if (value !== undefined) headers[name] = value;
}
let body: string | undefined;
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json';
Expand Down Expand Up @@ -142,6 +187,21 @@ const decodeBody = async (response: Response): Promise<unknown> => {
return response.text();
};

// `bytes 0-1023/8192`. Absent on a whole-file response.
const CONTENT_RANGE_RE = /^bytes (\d+)-(\d+)\/(\d+)$/;

export const parseContentRange = (header: string | null): ContentRange | null => {
const match = header?.match(CONTENT_RANGE_RE);
if (!match) return null;
return { start: Number(match[1]), end: Number(match[2]), size: Number(match[3]) };
};

const parseHttpDate = (header: string | null): Date | null => {
if (!header) return null;
const date = new Date(header);
return Number.isNaN(date.getTime()) ? null : date;
};

const errorMessage = (body: unknown, statusCode: number): string => {
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') {
return body.error;
Expand Down
3 changes: 3 additions & 0 deletions src/_config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Generated by oagen. DO NOT EDIT.


const IN_CLUSTER_BASE_URL = 'http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080';
const DEFAULT_TIMEOUT_MS = 30_000;

Expand Down
77 changes: 76 additions & 1 deletion src/_models.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by oagen. DO NOT EDIT.


import type { FilterValuesResponsePhases, LogLineLevel, SandboxDomainSpecVisibility, StatusResponsePhase, VolumePhase } from './enums.js';
import type { FilterValuesResponsePhases, LogLineLevel, SandboxDomainSpecVisibility, StatusResponsePhase, VolumeFileEntryType, VolumePhase } from './enums.js';

export interface CountPoint {
/** Start of the bucket. */
Expand Down Expand Up @@ -133,6 +133,22 @@ export interface SandboxDomainSpec {
visibility?: SandboxDomainSpecVisibility;
}

export interface SandboxEgressSpec {
/**
* Destinations the sandbox may reach; all other outbound traffic is
* denied. An entry is a hostname (api.example.com), a wildcard
* (*.example.com) matching any host under that domain but not the
* domain itself, an IP literal, a CIDR range (203.0.113.0/24), or the
* cluster-internal hostname of a Service on the sandbox's cluster
* (name.namespace.svc.cluster.local), which allows the Service's
* backing pods as they change. Enforcement is transparent at the
* network layer, so any protocol and client works without proxy
* configuration. An empty list denies all egress; omit egress entirely
* to leave the sandbox's internet access unrestricted.
*/
allowed_destinations: string[];
}

export interface SandboxNetworkingSpec {
/**
* Port the workload listens on; the per-sandbox Service targets it on
Expand Down Expand Up @@ -181,6 +197,7 @@ export interface SandboxSpec {
* only one entry is supported.
*/
networking?: SandboxNetworkingSpec[];
egress?: SandboxEgressSpec;
/**
* Maximum lifetime in seconds, counted from creation. The sandbox is
* terminated once it elapses. Omit for no limit.
Expand Down Expand Up @@ -224,6 +241,12 @@ export interface Volume {
id: string;
/** Volume name */
name: string;
/**
* Subdirectory, relative to the shared sandbox volumes mount, where this
* volume's data lives. An app that mounts the cluster's sandbox volumes
* reads this volume at <mount>/<path>.
*/
path: string;
/** Current lifecycle phase of the volume */
phase: VolumePhase;
/** IDs of sandboxes the volume is attached to */
Expand All @@ -232,6 +255,58 @@ export interface Volume {
created_at: string;
}

export interface VolumeFileEntry {
/**
* Entry name within its parent directory, without any path separator
* (e.g. model.bin). Entries nest, so a directory name is carried once
* on the directory itself rather than repeated in the path of every
* entry beneath it. Reconstruct an entry's path relative to the volume
* root by joining the names from the listing's path down to the entry.
*/
name: string;
/** Whether the entry is a regular file or a directory */
type: VolumeFileEntryType;
/** Size of the entry in bytes. Zero for directories. */
size_bytes: number;
/**
* When the entry was last modified. Always set on files; may be absent
* on directories when the volume's storage keeps no directory
* modification time.
*/
modified_at?: string;
/**
* Set on a directory entry whose contents the walk did not fully read:
* the entry budget ran out before entering it, or the directory alone
* holds more entries than the budget. List the directory directly to
* read more of them. Never set on files.
*/
truncated?: boolean;
/**
* Entries directly inside this directory, directories before files and
* each group by name. Omitted on files, and on a directory whose
* entries the walk did not read (which carries truncated instead).
*/
entries?: VolumeFileEntry[];
}

export interface VolumeFileListResponse {
/** The walked directory, relative to the volume root */
path: string;
/**
* Entries directly inside the walked directory, directories before
* files and each group by name. Entries nest, so everything the walk
* read below this level hangs off these entries rather than appearing
* here as a flat path.
*/
entries: VolumeFileEntry[];
/**
* Whether the walk stopped at its entry budget before reading every
* entry. Directories left unread or partially read carry their own
* truncated marker.
*/
truncated: boolean;
}

export interface VolumeListResponse {
/** All volumes in the cluster */
volumes: Volume[];
Expand Down
3 changes: 3 additions & 0 deletions src/_retries.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Generated by oagen. DO NOT EDIT.


export const DEFAULT_MAX_RETRIES = 3;
const INITIAL_BACKOFF_MS = 500;
const MAX_BACKOFF_MS = 8_000;
Expand Down
Loading
Loading