From e5d6b3fa2eca63a547845e6fd3a6c491fcfdb232 Mon Sep 17 00:00:00 2001 From: Jack Decker <24392469+jackowfish@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:03:14 -0400 Subject: [PATCH] feat: volume file reads --- README.md | 32 +++--- package.json | 3 +- scripts/sync-generated.sh | 67 ------------- src/_baseClient.ts | 78 +++++++++++++-- src/_config.ts | 3 + src/_models.ts | 77 ++++++++++++++- src/_retries.ts | 3 + src/enums.ts | 6 ++ src/index.ts | 24 ++++- src/resources/volumes.ts | 40 +++++++- src/sandbox.ts | 3 + src/volume.ts | 202 ++++++++++++++++++++++++++++++++++++++ src/volumes.ts | 14 ++- tests/models.test.ts | 20 +++- tests/url.test.ts | 27 +++++ 15 files changed, 498 insertions(+), 101 deletions(-) delete mode 100755 scripts/sync-generated.sh create mode 100644 src/volume.ts create mode 100644 tests/url.test.ts diff --git a/README.md b/README.md index 865f822..c7c3312 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 -``` diff --git a/package.json b/package.json index d1781de..8103d32 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/sync-generated.sh b/scripts/sync-generated.sh deleted file mode 100755 index 42ed15c..0000000 --- a/scripts/sync-generated.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# Sync generated SDK code from the sdk-gen workspace into this repo. -# -# Usage: ./scripts/sync-generated.sh -# -# Copies only files that the emitter owns. Hand-written runtime/domain files -# (_baseClient.ts, _config.ts, _retries.ts, sandbox.ts, index.ts) are -# preserved. - -set -euo pipefail - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -SRC="$1" -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -if [[ ! -d "$SRC/src" ]]; then - echo "Error: $SRC/src not found" >&2 - exit 1 -fi - -# Files the emitter owns (overwrite freely). -GENERATED_FILES=( - src/_client.ts - src/_errors.ts - src/_models.ts - src/enums.ts -) - -for f in "${GENERATED_FILES[@]}"; do - if [[ -f "$SRC/$f" ]]; then - cp "$SRC/$f" "$REPO_ROOT/$f" - echo " copied $f" - fi -done - -# Public client and namespace wrappers are generated too. Copy top-level -# generated modules, but preserve hand-written runtime/domain modules. -# Note: src/index.ts is hand-written (it re-exports the public client/domain -# classes alongside generated wrappers). Don't sync it. -for f in "$SRC"/src/*.ts; do - name="$(basename "$f")" - case "$name" in - index.ts|_*.ts|enums.ts|sandbox.ts) - continue - ;; - esac - cp "$f" "$REPO_ROOT/src/$name" - echo " copied src/$name" -done - -# Resources are a whole directory. -rm -rf "$REPO_ROOT/src/resources" -cp -r "$SRC/src/resources" "$REPO_ROOT/src/resources" -echo " copied src/resources/" - -# Generated tests go alongside hand-written ones. -mkdir -p "$REPO_ROOT/tests" -if [[ -f "$SRC/tests/models.test.ts" ]]; then - cp "$SRC/tests/models.test.ts" "$REPO_ROOT/tests/models.test.ts" - echo " copied tests/models.test.ts" -fi - -echo "Done." diff --git a/src/_baseClient.ts b/src/_baseClient.ts index 2740a61..b52e214 100644 --- a/src/_baseClient.ts +++ b/src/_baseClient.ts @@ -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'; @@ -12,6 +15,7 @@ export interface RequestOptions { method: string; path: string; query?: Record | undefined; + headers?: Record | 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 @@ -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(...)`. +// `this.client.request(...)`, or `requestBinary(...)` for the endpoints +// that answer with bytes rather than JSON. export class BaseClient { private readonly config: Config; private readonly maxRetries: number; @@ -41,8 +62,25 @@ export class BaseClient { } async request(options: RequestOptions): Promise { + const response = await this.send(options, 'application/json'); + return (await decodeBody(response)) as T; + } + + async requestBinary(options: RequestOptions): Promise { + 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 { 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; @@ -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); @@ -81,8 +117,14 @@ export class BaseClient { } } -const buildUrl = (baseUrl: string, path: string, query: Record | undefined): string => { - const url = new URL(path, `${baseUrl}/`); +export const buildUrl = (baseUrl: string, path: string, query: Record | 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; @@ -96,14 +138,17 @@ const buildUrl = (baseUrl: string, path: string, query: Record return url.toString(); }; -const buildInit = (config: Config, options: RequestOptions): RequestInit => { +const buildInit = (config: Config, options: RequestOptions, accept: string): RequestInit => { const headers: Record = { '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'; @@ -142,6 +187,21 @@ const decodeBody = async (response: Response): Promise => { 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; diff --git a/src/_config.ts b/src/_config.ts index e538186..4ab6633 100644 --- a/src/_config.ts +++ b/src/_config.ts @@ -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; diff --git a/src/_models.ts b/src/_models.ts index f32f98b..54afbf9 100644 --- a/src/_models.ts +++ b/src/_models.ts @@ -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. */ @@ -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 @@ -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. @@ -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 /. + */ + path: string; /** Current lifecycle phase of the volume */ phase: VolumePhase; /** IDs of sandboxes the volume is attached to */ @@ -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[]; diff --git a/src/_retries.ts b/src/_retries.ts index 5050464..aabb09b 100644 --- a/src/_retries.ts +++ b/src/_retries.ts @@ -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; diff --git a/src/enums.ts b/src/enums.ts index 8cfb2bf..557345a 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -44,6 +44,12 @@ export const StatusResponsePhase = { } as const; export type StatusResponsePhase = typeof StatusResponsePhase[keyof typeof StatusResponsePhase]; +export const VolumeFileEntryType = { + File: "file", + Directory: "directory", +} as const; +export type VolumeFileEntryType = typeof VolumeFileEntryType[keyof typeof VolumeFileEntryType]; + export const VolumePhase = { Pending: "pending", Ready: "ready", diff --git a/src/index.ts b/src/index.ts index c7e1193..c331b36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,52 @@ +// Generated by oagen. DO NOT EDIT. + + export { Porter } from './porter.js'; export { Sandbox } from './sandbox.js'; +export { Volume, VolumeFile } from './volume.js'; +export { Healthz } from './healthz.js'; +export { Readyz } from './readyz.js'; export { Sandboxes } from './sandboxes.js'; export { Volumes } from './volumes.js'; export { PorterSandboxApiClient } from './_client.js'; -export type { ClientOptions } from './_baseClient.js'; +export type { BinaryContent, ClientOptions, ContentRange } from './_baseClient.js'; export { AuthenticationError, NotFoundError, RateLimitError, SandboxError, + SandboxTimeoutError, ServerError, } from './_errors.js'; export type { + CountPoint, + CountResponse, CreateResponse, + Error, ExecRequest, ExecResponse, + ExecTarget, + FilterValuesResponse, HealthResponse, ListResponse, LogLine, LogsResponse, + LookupResult, Pagination, ReadinessResponse, + SandboxDomainSpec, + SandboxEgressSpec, + SandboxNetworkingSpec, SandboxSpec, StatusResponse, - Volume, + Volume as VolumeRecord, + VolumeFileEntry, + VolumeFileListResponse, VolumeListResponse, VolumeSpec, } from './_models.js'; -export { LogLineLevel, StatusResponsePhase, VolumePhase } from './enums.js'; +export { FilterValuesResponsePhases, LogLineLevel, SandboxDomainSpecVisibility, SandboxesPhase, StatusResponsePhase, VolumeFileEntryType, VolumePhase } from './enums.js'; diff --git a/src/resources/volumes.ts b/src/resources/volumes.ts index e9edde8..ae88677 100644 --- a/src/resources/volumes.ts +++ b/src/resources/volumes.ts @@ -1,8 +1,8 @@ // Generated by oagen. DO NOT EDIT. -import type { BaseClient } from '../_baseClient.js'; -import type { LookupResult, Volume, VolumeListResponse, VolumeSpec } from '../_models.js'; +import type { BaseClient, BinaryContent } from '../_baseClient.js'; +import type { LookupResult, Volume, VolumeFileListResponse, VolumeListResponse, VolumeSpec } from '../_models.js'; /** Volumes resource. */ export class Volumes { @@ -70,4 +70,40 @@ export class Volumes { path: `/v1/volume/${id}`, }); } + + /** + * List volume files + * + * List the files and directories under a path inside a volume, read from + * the shared sandbox volumes mount. The listing walks the tree + * breadth-first up to a server-side entry budget: small trees come back + * complete in one response, and directories the walk did not fully read + * are marked truncated for clients to list directly. Requires the control + * plane to have the mount configured; without it every listing reports + * the files API as unavailable. + */ + async listFiles(id: string, options?: { path?: string; search?: string }): Promise { + return this.client.request({ + method: 'GET', + path: `/v1/volume/${id}/files`, + query: { path: options?.path, search: options?.search }, + }); + } + + /** + * Read volume file + * + * Stream a file's raw bytes from a volume. The response is the file + * content itself, so clients bound how much they read: send a single + * byte range (e.g. bytes=0-1048575) to read part of the file, or omit + * the Range header for the whole file. + */ + async readFile(id: string, options: { path: string; range?: string }): Promise { + return this.client.requestBinary({ + method: 'GET', + path: `/v1/volume/${id}/files/content`, + query: { path: options.path }, + headers: { Range: options.range }, + }); + } } diff --git a/src/sandbox.ts b/src/sandbox.ts index 004a54f..015f675 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -1,3 +1,6 @@ +// Generated by oagen. DO NOT EDIT. + + import type { Sandboxes } from './resources/sandboxes.js'; import type { ExecResponse, LogLine, StatusResponse } from './_models.js'; import type { StatusResponsePhase } from './enums.js'; diff --git a/src/volume.ts b/src/volume.ts new file mode 100644 index 0000000..50c5bd3 --- /dev/null +++ b/src/volume.ts @@ -0,0 +1,202 @@ +// Generated by oagen. DO NOT EDIT. + + +import type { Volume as VolumeRecord, VolumeFileEntry } from './_models.js'; +import type { VolumeFileEntryType, VolumePhase } from './enums.js'; +import type { Volumes as VolumesResource } from './resources/volumes.js'; + +// How much of a file one request of `stream` reads. Files are read through +// ranged requests, so this bounds how many bytes a caller holds at once. +const DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024; + +// Volume paths are relative to the volume root. Accept them with or without a +// leading slash, and hand the API the '/'-rooted form its listings echo back. +const normalizePath = (path: string): string => { + const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, ''); + return trimmed === '' ? '/' : `/${trimmed}`; +}; + +const joinPath = (parent: string, name: string): string => + parent === '/' ? `/${name}` : `${parent}/${name}`; + +const byteRangeHeader = (offset: number, length: number | undefined): string => + length === undefined ? `bytes=${offset}-` : `bytes=${offset}-${offset + length - 1}`; + +/** A file or directory inside a volume. */ +export class VolumeFile { + /** Entry name within its parent directory, without any path separator. */ + readonly name: string; + /** Path relative to the volume root, e.g. `/models/config.json`. */ + readonly path: string; + readonly type: VolumeFileEntryType; + readonly sizeBytes: number; + readonly modifiedAt: Date | null; + /** + * Set on a directory the server's listing walk did not fully read — it holds + * more entries than one listing covers. `iterdir` reads these directly; + * `listdir` leaves them for the caller to list. + */ + readonly truncated: boolean; + + constructor(entry: VolumeFileEntry, parentPath: string) { + this.name = entry.name; + this.path = joinPath(parentPath, entry.name); + this.type = entry.type; + this.sizeBytes = entry.size_bytes; + this.modifiedAt = entry.modified_at ? new Date(entry.modified_at) : null; + this.truncated = entry.truncated ?? false; + } + + get isDirectory(): boolean { + return this.type === 'directory'; + } + + get isFile(): boolean { + return this.type === 'file'; + } +} + +// Rich handle for a single volume. Constructed by the Volumes namespace — +// users don't instantiate this directly. Holds a back-reference to the +// underlying generated resource so file and lifecycle operations don't need to +// be re-passed a client. +export class Volume { + private record: VolumeRecord; + private readonly resource: VolumesResource; + + constructor(args: { record: VolumeRecord; resource: VolumesResource }) { + this.record = args.record; + this.resource = args.resource; + } + + get id(): string { + return this.record.id; + } + + get name(): string { + return this.record.name; + } + + get phase(): VolumePhase { + return this.record.phase; + } + + /** IDs of the sandboxes the volume is attached to. */ + get attachedTo(): string[] { + return this.record.attached_to; + } + + get createdAt(): Date { + return new Date(this.record.created_at); + } + + async refresh(): Promise { + this.record = await this.resource.get(this.id); + return this.record; + } + + /** Delete the volume. Fails while it is attached to a sandbox. */ + async delete(): Promise { + await this.resource.delete(this.id); + } + + /** The entries directly inside `path`, directories first and each group by name. */ + async listdir(path = '/'): Promise { + const parent = normalizePath(path); + const listing = await this.resource.listFiles(this.id, { path: parent }); + return listing.entries.map((entry) => new VolumeFile(entry, parent)); + } + + /** + * Every entry under `path`, depth-first. One listing covers a bounded number + * of entries, so directories it left unread are listed as the walk reaches + * them — a large tree costs more than one request. + */ + async *iterdir(path = '/'): AsyncGenerator { + const parent = normalizePath(path); + const listing = await this.resource.listFiles(this.id, { path: parent }); + yield* this.walkEntries(parent, listing.entries, true); + } + + /** + * Entries under `path` whose name contains `query`, case-insensitively. The + * search runs inside one listing walk, so a tree too large for a single + * listing is searched as far as that walk reached. + */ + async search(query: string, options: { path?: string } = {}): Promise { + const parent = normalizePath(options.path ?? '/'); + const listing = await this.resource.listFiles(this.id, { path: parent, search: query }); + const needle = query.toLowerCase(); + // The API prunes the tree to matches plus the directories holding them; + // keep only the entries that match so callers get the hits alone. + const matches: VolumeFile[] = []; + for await (const file of this.walkEntries(parent, listing.entries, false)) { + if (file.name.toLowerCase().includes(needle)) matches.push(file); + } + return matches; + } + + /** + * Read a file's bytes. `offset` and `length` read a slice of it instead of + * the whole file. + */ + async readFile(path: string, options: { offset?: number; length?: number } = {}): Promise { + const ranged = options.offset !== undefined || options.length !== undefined; + const content = await this.resource.readFile(this.id, { + path: normalizePath(path), + range: ranged ? byteRangeHeader(options.offset ?? 0, options.length) : undefined, + }); + return content.bytes; + } + + /** Read a file as UTF-8 text. */ + async readText(path: string, options: { offset?: number; length?: number } = {}): Promise { + return new TextDecoder().decode(await this.readFile(path, options)); + } + + /** + * Read a file in chunks, so a large one never lands in memory whole. Each + * chunk is its own ranged request. + */ + async *stream(path: string, options: { chunkSize?: number } = {}): AsyncGenerator { + const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_BYTES; + const filePath = normalizePath(path); + let offset = 0; + + for (;;) { + const content = await this.resource.readFile(this.id, { + path: filePath, + range: byteRangeHeader(offset, chunkSize), + }); + if (content.bytes.length > 0) yield content.bytes; + + // An empty file comes back whole, with no Content-Range to page from. + const range = content.contentRange; + if (!range) return; + + offset = range.end + 1; + if (offset >= range.size) return; + } + } + + // Depth-first over a listing's nested entries. `followTruncated` lists the + // directories the server's walk left unread, which `iterdir` wants and + // `search` does not: listing one of those directly would drop the search + // term and pull in everything under it. + private async *walkEntries( + parent: string, + entries: VolumeFileEntry[], + followTruncated: boolean, + ): AsyncGenerator { + for (const entry of entries) { + const file = new VolumeFile(entry, parent); + yield file; + if (!file.isDirectory) continue; + if (entry.entries) { + yield* this.walkEntries(file.path, entry.entries, followTruncated); + } else if (file.truncated && followTruncated) { + yield* this.iterdir(file.path); + } + } + } +} diff --git a/src/volumes.ts b/src/volumes.ts index a3335b1..6a1c695 100644 --- a/src/volumes.ts +++ b/src/volumes.ts @@ -1,8 +1,9 @@ // Generated by oagen. DO NOT EDIT. -import type { Volume, VolumeListResponse, VolumeSpec } from './_models.js'; +import type { VolumeSpec } from './_models.js'; import type { Volumes as VolumesResource } from './resources/volumes.js'; +import { Volume } from './volume.js'; /** User-facing volumes namespace under `Porter`. */ export class Volumes { @@ -14,16 +15,19 @@ export class Volumes { } async create(body: VolumeSpec): Promise { - return this.resource.create(body); + const record = await this.resource.create(body); + return new Volume({ record, resource: this.resource }); } - async list(): Promise { - return this.resource.list(); + async list(): Promise { + const response = await this.resource.list(); + return response.volumes.map((record) => new Volume({ record, resource: this.resource })); } async get(name: string): Promise { const { id } = await this.resource.lookup({ name }); - return this.resource.get(id); + const record = await this.resource.get(id); + return new Volume({ record, resource: this.resource }); } async delete(name: string): Promise { diff --git a/tests/models.test.ts b/tests/models.test.ts index 12bda2c..fe20188 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; -import type { CountPoint, CountResponse, CreateResponse, Error, ExecRequest, ExecResponse, ExecTarget, FilterValuesResponse, HealthResponse, LogsResponse, LookupResult, Pagination, ReadinessResponse, SandboxDomainSpec, SandboxNetworkingSpec, SandboxSpec, VolumeListResponse, VolumeSpec } from '../src/_models.js'; +import type { CountPoint, CountResponse, CreateResponse, Error, ExecRequest, ExecResponse, ExecTarget, FilterValuesResponse, HealthResponse, LogsResponse, LookupResult, Pagination, ReadinessResponse, SandboxDomainSpec, SandboxEgressSpec, SandboxNetworkingSpec, SandboxSpec, VolumeFileListResponse, VolumeListResponse, VolumeSpec } from '../src/_models.js'; describe('model round trip', () => { it('accepts a structurally valid CountPoint', () => { @@ -128,6 +128,14 @@ describe('model round trip', () => { expect(roundTripped).toEqual(value); }); + it('accepts a structurally valid SandboxEgressSpec', () => { + const value: SandboxEgressSpec = { + allowed_destinations: [], + }; + const roundTripped = JSON.parse(JSON.stringify(value)) as SandboxEgressSpec; + expect(roundTripped).toEqual(value); + }); + it('accepts a structurally valid SandboxNetworkingSpec', () => { const value: SandboxNetworkingSpec = { port: 1, @@ -144,6 +152,16 @@ describe('model round trip', () => { expect(roundTripped).toEqual(value); }); + it('accepts a structurally valid VolumeFileListResponse', () => { + const value: VolumeFileListResponse = { + path: 'x', + entries: [], + truncated: true, + }; + const roundTripped = JSON.parse(JSON.stringify(value)) as VolumeFileListResponse; + expect(roundTripped).toEqual(value); + }); + it('accepts a structurally valid VolumeListResponse', () => { const value: VolumeListResponse = { volumes: [], diff --git a/tests/url.test.ts b/tests/url.test.ts new file mode 100644 index 0000000..e795d50 --- /dev/null +++ b/tests/url.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { buildUrl } from '../src/_baseClient.js'; + +describe('buildUrl', () => { + it('targets the in-cluster sandbox API at its root', () => { + const base = 'http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080'; + expect(buildUrl(base, '/v1/sandbox/run', undefined)).toBe(`${base}/v1/sandbox/run`); + }); + + it('preserves a proxied base URL path prefix (Porter gateway pass-through)', () => { + const base = 'https://host/api/v2/alpha/projects/1/clusters/2/sandboxes'; + expect(buildUrl(base, '/v1/sandbox/run', undefined)).toBe( + 'https://host/api/v2/alpha/projects/1/clusters/2/sandboxes/v1/sandbox/run', + ); + }); + + it('tolerates a trailing slash on the base URL', () => { + expect(buildUrl('https://host/prefix/', '/v1/sandbox/abc', undefined)).toBe( + 'https://host/prefix/v1/sandbox/abc', + ); + }); + + it('appends query params, repeating array values', () => { + const url = buildUrl('http://h:8080', '/v1/sandbox', { tag: ['env=prod', 'owner=alice'], page: 1 }); + expect(url).toBe('http://h:8080/v1/sandbox?tag=env%3Dprod&tag=owner%3Dalice&page=1'); + }); +});