diff --git a/.changeset/sandbox-sdk.md b/.changeset/sandbox-sdk.md new file mode 100644 index 0000000..2b8e883 --- /dev/null +++ b/.changeset/sandbox-sdk.md @@ -0,0 +1,6 @@ +--- +"@bunny.net/sandbox": minor +"@bunny.net/cli": minor +--- + +Add @bunny.net/sandbox SDK for programmatic sandbox create, file buffering, command execution, and port exposure; wire sandbox CLI commands onto it diff --git a/AGENTS.md b/AGENTS.md index 8048223..bfcb462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,8 @@ This is a Bun workspace monorepo with four packages: - **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. - **`@bunny.net/app-config`** (`packages/app-config/`) — Shared app configuration schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. Used by the CLI and potentially other tools. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). -- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, and `@bunny.net/database-shell`. +- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Zero CLI dependencies. +- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, `@bunny.net/database-shell`, and `@bunny.net/sandbox`. ``` bunny-cli/ @@ -144,6 +145,19 @@ bunny-cli/ │ │ ├── types.ts # ShellLogger, ShellOptions, PrintMode │ │ └── shell.test.ts # Tests for shell utilities │ │ +│ ├── sandbox/ # @bunny.net/sandbox package +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ ├── index.ts # Barrel export: Sandbox, Command, types +│ │ ├── sandbox.ts # Sandbox class: create/get/fromHandle, runCommand, writeFiles, readFile, mkDir, exposePort, domain, delete +│ │ ├── provision.ts # Magic Containers app create/poll/endpoints + auth helpers +│ │ ├── transport.ts # ssh2 SSH/SFTP transport (exec, file IO, reachability) +│ │ ├── command.ts # Command (detached, logs()) and CommandFinished +│ │ ├── types.ts # Option and handle types +│ │ ├── errors.ts # SandboxError +│ │ └── sandbox.test.ts # Tests for pure logic (command building, app extraction) +│ │ │ └── cli/ # @bunny.net/cli package │ ├── package.json │ ├── tsconfig.json @@ -349,7 +363,7 @@ bunny-cli/ ### Conventions -- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/app-config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/cli/` is the CLI. +- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/app-config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/sandbox/` is the standalone sandbox SDK (provisioning + SSH transport); `packages/cli/` is the CLI. - **API clients use `ClientOptions`** — an options object with `apiKey`, `baseUrl`, `verbose`, `userAgent`, and `onDebug`. The CLI provides a `clientOptions(config, verbose)` helper to build this from `ResolvedConfig`. - **One command per file.** Each file in `commands/` exports a single command or namespace. - **Commands are grouped by domain** in subdirectories (`config/`, `db/`, `scripts/`). diff --git a/CLAUDE.md b/CLAUDE.md index 127c9c3..581a29c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,12 +26,13 @@ test("example", () => { ## Monorepo structure -This is a Bun workspace monorepo with four packages: +This is a Bun workspace monorepo with five packages: - `packages/openapi-client/` (`@bunny.net/openapi-client`) — standalone, type-safe OpenAPI client, zero CLI deps - `packages/app-config/` (`@bunny.net/app-config`) — shared Zod schemas, types, and JSON Schema for `bunny.jsonc` - `packages/database-shell/` (`@bunny.net/database-shell`) — standalone SQL shell engine (REPL, formatting, masking) -- `packages/cli/` (`@bunny.net/cli`) — the CLI, depends on all three +- `packages/sandbox/` (`@bunny.net/sandbox`) — standalone sandbox SDK (create, file buffering, command exec, port exposure) over Magic Containers + SSH +- `packages/cli/` (`@bunny.net/cli`) — the CLI, depends on the other four ## Project conventions diff --git a/bun.lock b/bun.lock index ecbaf2d..9b4f285 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "bun-ny-cli", @@ -34,6 +33,7 @@ "@bunny.net/database-shell": "workspace:*", "@bunny.net/database-studio": "workspace:*", "@bunny.net/openapi-client": "workspace:*", + "@bunny.net/sandbox": "workspace:*", "@libsql/client": "^0.17.0", "@types/prompts": "^2.4.9", "@types/yargs": "^17.0.35", @@ -47,11 +47,11 @@ "zod": "^4.3.6", }, "optionalDependencies": { - "@bunny.net/cli-darwin-arm64": "0.4.2", - "@bunny.net/cli-darwin-x64": "0.4.2", - "@bunny.net/cli-linux-arm64": "0.4.2", - "@bunny.net/cli-linux-x64": "0.4.2", - "@bunny.net/cli-windows-x64": "0.4.2", + "@bunny.net/cli-darwin-arm64": "0.7.0", + "@bunny.net/cli-darwin-x64": "0.7.0", + "@bunny.net/cli-linux-arm64": "0.7.0", + "@bunny.net/cli-linux-x64": "0.7.0", + "@bunny.net/cli-windows-x64": "0.7.0", }, }, "packages/cli-darwin-arm64": { @@ -182,6 +182,15 @@ "typescript": "^5", }, }, + "packages/sandbox": { + "name": "@bunny.net/sandbox", + "version": "0.1.0", + "dependencies": { + "@bunny.net/openapi-client": "workspace:*", + "@types/ssh2": "^1.15.0", + "ssh2": "^1.16.0", + }, + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -278,6 +287,8 @@ "@bunny.net/openapi-client": ["@bunny.net/openapi-client@workspace:packages/openapi-client"], + "@bunny.net/sandbox": ["@bunny.net/sandbox@workspace:packages/sandbox"], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], @@ -602,6 +613,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -630,10 +643,14 @@ "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="], + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -642,6 +659,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], @@ -668,6 +687,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -842,6 +863,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -950,6 +973,8 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "stdin-discarder": ["stdin-discarder@0.3.1", "", {}, "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -978,6 +1003,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1066,6 +1093,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1086,6 +1115,8 @@ "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 0b3c363..39ffdc5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "@bunny.net/openapi-client": "workspace:*", "@bunny.net/app-config": "workspace:*", "@bunny.net/database-shell": "workspace:*", + "@bunny.net/sandbox": "workspace:*", "@bunny.net/database-studio": "workspace:*", "@libsql/client": "^0.17.0", "@types/prompts": "^2.4.9", diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index fb0a710..5d450dd 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -1,108 +1,12 @@ -import { randomBytes } from "node:crypto"; -import { createMcClient } from "@bunny.net/openapi-client"; +import { Sandbox, SandboxError } from "@bunny.net/sandbox"; import prompts from "prompts"; import { resolveConfig, setSandbox } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; -import { WORKPLACE } from "./ssh-exec.ts"; -const IMAGE_REGISTRY_ID = "1156"; -const IMAGE_NAMESPACE = "bunnyway"; -const IMAGE_NAME = "sandbox-agent"; -const IMAGE_TAG = "latest"; const DEFAULT_REGION = "AMS"; -const POLL_INTERVAL_MS = 3000; -const STARTUP_TIMEOUT_MS = 120_000; - -type App = Record & { - id?: string; - status?: string; - containerTemplates?: Array<{ - endpoints?: Array< - Record & { type?: string; publicHost?: string } - >; - }>; -}; - -function generateToken(): string { - return randomBytes(32).toString("base64url"); -} - -function extractAnycastHost(app: App): string | null { - for (const ct of app.containerTemplates ?? []) { - for (const ep of ct.endpoints ?? []) { - if (ep.type === "anycast") { - return (ep.publicHost as string | undefined) ?? null; - } - } - } - return null; -} - -async function probeSsh(host: string, port: number): Promise { - try { - const socket = await Bun.connect({ - hostname: host, - port, - socket: { data() {}, open() {}, close() {}, error() {} }, - }); - socket.end(); - return true; - } catch { - return false; - } -} - -async function waitUntilActive( - client: ReturnType, - appId: string, -): Promise<{ sshHost: string }> { - const deadline = Date.now() + STARTUP_TIMEOUT_MS; - - // Phase 1: poll API until the anycast SSH endpoint is assigned - let sshHost: string | null = null; - await Bun.sleep(3000); - while (Date.now() < deadline) { - const { data, error } = await client.GET("/apps/{appId}", { - params: { path: { appId } }, - }); - if (error) - throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); - const app = data as App; - const status = (app as Record).status as - | string - | undefined; - if (status === "failing" || status === "suspended") { - throw new UserError(`Sandbox entered terminal state: ${status}`); - } - sshHost = extractAnycastHost(app); - if (sshHost) break; - await Bun.sleep(POLL_INTERVAL_MS); - } - - if (!sshHost) { - throw new UserError( - `Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`, - ); - } - - // Phase 2: probe SSH port until the container accepts connections - const [sshIp, sshPortStr] = ( - sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] - ) as [string, string]; - const sshPort = Number(sshPortStr); - while (Date.now() < deadline) { - if (await probeSsh(sshIp, sshPort)) return { sshHost }; - await Bun.sleep(POLL_INTERVAL_MS); - } - - throw new UserError( - `Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`, - ); -} interface CreateArgs { name?: string; @@ -135,7 +39,6 @@ export const sandboxCreateCommand = defineCommand({ handler: async ({ profile, verbose, apiKey, name, region, output }) => { const config = resolveConfig(profile, apiKey, verbose); - const client = createMcClient(clientOptions(config, verbose)); // JSON output stays non-interactive; the name must come from the positional. const interactive = output !== "json"; @@ -151,90 +54,49 @@ export const sandboxCreateCommand = defineCommand({ } if (!sandboxName) throw new UserError("A sandbox name is required."); - const agentToken = generateToken(); - const spin = spinner("Creating sandbox..."); spin.start(); - const { data: app, error: createError } = await (client as any).POST( - "/apps", - { - body: { - name: sandboxName, - runtimeType: "shared", - autoScaling: { min: 1, max: 1 }, - regionSettings: { - allowedRegionIds: [region], - requiredRegionIds: [region], - }, - volumes: [{ name: "workplace", size: 10 }], - containerTemplates: [ - { - name: "agent", - imageRegistryId: IMAGE_REGISTRY_ID, - imageNamespace: IMAGE_NAMESPACE, - imageName: IMAGE_NAME, - imageTag: IMAGE_TAG, - imagePullPolicy: "ifNotPresent", - environmentVariables: [ - { name: "AGENT_TOKEN", value: agentToken }, - ], - volumeMounts: [{ name: "workplace", mountPath: WORKPLACE }], - endpoints: [ - { - displayName: "ssh", - anycast: { - type: "ipv4", - portMappings: [ - { - containerPort: 8023, - exposedPort: 8023, - protocols: ["Tcp"], - }, - ], - }, - }, - ], - }, - ], - }, - }, - ); - - if (createError || !app) { - spin.stop(); - throw new UserError( - `Failed to create sandbox: ${JSON.stringify(createError)}`, - ); - } - - const appId = (app as Record).id as string; - spin.text = "Waiting for sandbox to become active..."; - - let sshHost: string; + let sandbox: Sandbox; try { - ({ sshHost } = await waitUntilActive(client, appId)); + sandbox = await Sandbox.create({ + apiKey: config.apiKey, + apiUrl: config.apiUrl, + verbose, + onDebug: (msg) => logger.debug(msg, true), + name: sandboxName, + region, + }); } catch (err) { spin.stop(); - // best-effort cleanup - await client - .DELETE("/apps/{appId}", { params: { path: { appId } } }) - .catch(() => {}); + if (err instanceof SandboxError) throw new UserError(err.message); throw err; } spin.stop(); - const record = { - app_id: appId, - agent_token: agentToken, - ssh_host: sshHost, - }; - setSandbox(sandboxName, record); + const handle = sandbox.toHandle(); + sandbox.disconnect(); + setSandbox(sandboxName, { + app_id: handle.appId, + agent_token: handle.agentToken, + ssh_host: handle.sshHost, + }); + + if (output === "json") { + logger.log( + JSON.stringify( + { name: sandboxName, appId: handle.appId, sshHost: handle.sshHost }, + null, + 2, + ), + ); + return; + } logger.log(`Sandbox "${sandboxName}" is ready.`); - logger.log(` App ID: ${appId}`); - logger.log(` SSH: ${sshHost}`); + logger.log(` App ID: ${handle.appId}`); + logger.log(` SSH: ${handle.sshHost}`); logger.log( `\nRun commands with: bunny sandbox exec ${sandboxName} `, ); diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts index eed4057..e6d7c79 100644 --- a/packages/cli/src/commands/sandbox/delete.ts +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -1,10 +1,9 @@ -import { createMcClient } from "@bunny.net/openapi-client"; +import { Sandbox, SandboxError } from "@bunny.net/sandbox"; import { deleteSandbox, getSandbox, resolveConfig, } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; @@ -55,21 +54,33 @@ export const sandboxDeleteCommand = defineCommand({ } const config = resolveConfig(profile, apiKey, verbose); - const client = createMcClient(clientOptions(config, verbose)); const spin = spinner("Deleting sandbox..."); spin.start(); - const { error } = await client.DELETE("/apps/{appId}", { - params: { path: { appId: record.app_id } }, - }); - - spin.stop(); - - if (error) { - throw new UserError(`Failed to delete app: ${JSON.stringify(error)}`); + try { + const sandbox = Sandbox.fromHandle( + { + appId: record.app_id, + name, + agentToken: record.agent_token, + sshHost: record.ssh_host ?? "", + }, + { + apiKey: config.apiKey, + apiUrl: config.apiUrl, + verbose, + onDebug: (msg) => logger.debug(msg, true), + }, + ); + await sandbox.delete(); + } catch (err) { + spin.stop(); + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; } + spin.stop(); deleteSandbox(name); logger.log(`Sandbox "${name}" deleted.`); }, diff --git a/packages/cli/src/commands/sandbox/url/add.ts b/packages/cli/src/commands/sandbox/url/add.ts index f50219d..1fb9346 100644 --- a/packages/cli/src/commands/sandbox/url/add.ts +++ b/packages/cli/src/commands/sandbox/url/add.ts @@ -1,6 +1,5 @@ -import { createMcClient } from "@bunny.net/openapi-client"; +import { Sandbox, SandboxError } from "@bunny.net/sandbox"; import { getSandbox, resolveConfig } from "../../../config/index.ts"; -import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; @@ -45,88 +44,43 @@ export const sandboxUrlAddCommand = defineCommand({ if (!record) throw new UserError(`No sandbox named "${name}" found.`); const config = resolveConfig(profile, apiKey, verbose); - const client = createMcClient(clientOptions(config, verbose)); - const spin = spinner("Fetching sandbox info..."); + const spin = spinner(`Exposing port ${port}...`); spin.start(); - // Get the container template ID from the app - const { data: app, error: appError } = await client.GET("/apps/{appId}", { - params: { path: { appId: record.app_id } }, - }); - if (appError || !app) { - spin.stop(); - throw new UserError(`Failed to fetch app: ${JSON.stringify(appError)}`); - } - - const containerId = (app as any).containerTemplates?.[0]?.id as - | string - | undefined; - if (!containerId) { - spin.stop(); - throw new UserError("Could not find container template ID."); - } - - const displayName = label ?? `port-${port}`; - spin.text = `Creating endpoint "${displayName}"...`; - - const { data: ep, error: epError } = await (client as any).POST( - "/apps/{appId}/containers/{containerId}/endpoints", - { - params: { path: { appId: record.app_id, containerId } }, - body: { - displayName, - cdn: { - isSslEnabled: false, - portMappings: [{ containerPort: port, protocols: ["Tcp"] }], - }, + let url: string; + try { + const sandbox = Sandbox.fromHandle( + { + appId: record.app_id, + name, + agentToken: record.agent_token, + sshHost: record.ssh_host ?? "", + }, + { + apiKey: config.apiKey, + apiUrl: config.apiUrl, + verbose, + onDebug: (msg) => logger.debug(msg, true), }, - }, - ); - - if (epError) { - spin.stop(); - throw new UserError( - `Failed to create endpoint: ${JSON.stringify(epError)}`, ); - } - - const endpointId = (ep as any)?.id as string; - - // Poll until publicHost is assigned - spin.text = "Waiting for public URL..."; - const deadline = Date.now() + 60_000; - let publicHost: string | null = null; - while (Date.now() < deadline) { - await Bun.sleep(2000); - const { data: list } = await client.GET("/apps/{appId}/endpoints", { - params: { path: { appId: record.app_id } }, - }); - const found = (list?.items ?? []).find((e: any) => e.id === endpointId); - if (found?.publicHost) { - publicHost = found.publicHost; - break; - } + url = await sandbox.exposePort(port, label); + } catch (err) { + spin.stop(); + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; } spin.stop(); + const displayName = label ?? `port-${port}`; if (output === "json") { - logger.log( - JSON.stringify( - { id: endpointId, displayName, port, publicHost }, - null, - 2, - ), - ); + logger.log(JSON.stringify({ displayName, port, url }, null, 2)); return; } logger.log(`Endpoint "${displayName}" created.`); logger.log(` Port: ${port}`); - logger.log(` ID: ${endpointId}`); - logger.log( - ` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`, - ); + logger.log(` URL: ${url}`); }, }); diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md new file mode 100644 index 0000000..fb69b07 --- /dev/null +++ b/packages/sandbox/README.md @@ -0,0 +1,98 @@ +# @bunny.net/sandbox + +Programmatically create bunny.net sandboxes, buffer files into them, run commands, and expose ports. Backed by Magic Containers, with a code-first developer experience. + +## Install + +```bash +bun add @bunny.net/sandbox +``` + +## Quick start + +```ts +import { Sandbox } from "@bunny.net/sandbox"; + +// Provision a sandbox and wait until it accepts connections. +const sandbox = await Sandbox.create({ + apiKey: process.env.BUNNYNET_API_KEY, + name: "my-sandbox", + region: "AMS", +}); + +// Buffer a file into the sandbox. +await sandbox.writeFiles([{ path: "server.js", content: Buffer.from("console.log('hi')") }]); + +// Run a command and read the result. +const result = await sandbox.runCommand("node", ["--version"]); +console.log(result.exitCode, await result.stdout()); + +// Expose a port as a public CDN URL. +const url = await sandbox.exposePort(3000); +console.log(url); + +await sandbox.delete(); +``` + +Authentication comes from the `apiKey` option or the `BUNNYNET_API_KEY` environment variable. + +## Long-running commands + +Pass `detached: true` to start a process and stream its output: + +```ts +const server = await sandbox.runCommand({ + cmd: "node", + args: ["server.js"], + detached: true, +}); + +for await (const { stream, data } of server.logs()) { + process[stream].write(data); +} + +const finished = await server.wait(); +console.log(finished.exitCode); +``` + +## Reconnecting + +`Sandbox.create` returns a handle you can persist and rebuild later, with no API round trip: + +```ts +const handle = sandbox.toHandle(); +// ...store handle somewhere... + +const same = Sandbox.fromHandle(handle, { apiKey }); +``` + +Or look a sandbox up by its app ID, recovering its connection details from the API: + +```ts +const sandbox = await Sandbox.get({ apiKey, appId }); +``` + +## API + +| Method | Description | +| --------------------------------------------- | ---------------------------------------------------- | +| `Sandbox.create(options)` | Provision a sandbox and wait until SSH is reachable. | +| `Sandbox.get({ appId })` | Retrieve an existing sandbox by app ID. | +| `Sandbox.fromHandle(handle)` | Rebuild a sandbox from a serialized handle. | +| `sandbox.runCommand(cmd, args)` | Run a command, blocking for the result. | +| `sandbox.runCommand({ ..., detached: true })` | Start a command and stream `logs()`. | +| `sandbox.writeFiles(files)` | Upload files, creating parent directories. | +| `sandbox.readFile(path)` | Read a file into a Buffer, or `null` if missing. | +| `sandbox.mkDir(path)` | Create a directory. | +| `sandbox.exposePort(port, label?)` | Expose a port as a public CDN URL. | +| `sandbox.domain(port)` | Return the URL of an already exposed port. | +| `sandbox.delete()` | Delete the sandbox and its backing app. | +| `sandbox.toHandle()` | Serialize the sandbox for reconnection. | + +## Not yet supported + +Magic Containers has no equivalent for these features, so they are intentionally omitted: `stop()`/resume, automatic timeouts, and snapshot/fork. Volumes persist across the sandbox lifetime, but there is no snapshot or branch API. + +## Transport + +Commands and file transfers run over SSH/SFTP (pure-JS [`ssh2`](https://github.com/mscdex/ssh2), no `sshpass` binary). The agent token generated at creation is used as the root password, and the connection targets the anycast SSH endpoint provisioned on the app. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json new file mode 100644 index 0000000..08b6bcf --- /dev/null +++ b/packages/sandbox/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bunny.net/sandbox", + "version": "0.1.0", + "type": "module", + "module": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "README.md" + ], + "dependencies": { + "@bunny.net/openapi-client": "workspace:*", + "@types/ssh2": "^1.15.0", + "ssh2": "^1.16.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sandbox/src/command.ts b/packages/sandbox/src/command.ts new file mode 100644 index 0000000..40ec485 --- /dev/null +++ b/packages/sandbox/src/command.ts @@ -0,0 +1,104 @@ +import type { ClientChannel } from "ssh2"; +import { SandboxError } from "./errors.ts"; + +export interface LogChunk { + stream: "stdout" | "stderr"; + data: string; +} + +export class CommandFinished { + constructor( + readonly exitCode: number | null, + private readonly _stdout: string, + private readonly _stderr: string, + ) {} + + async stdout(): Promise { + return this._stdout; + } + + async stderr(): Promise { + return this._stderr; + } +} + +/** A live command. Stream output via logs() and await wait() for the result. */ +export class Command { + exitCode: number | null = null; + private _stdout = ""; + private _stderr = ""; + private closed = false; + private readonly done: Promise; + private readonly queue: LogChunk[] = []; + private readonly waiters: Array<(r: IteratorResult) => void> = []; + + constructor(private readonly stream: ClientChannel) { + stream.on("data", (d: Buffer) => this.push("stdout", d.toString())); + stream.stderr.on("data", (d: Buffer) => this.push("stderr", d.toString())); + this.done = new Promise((resolve, reject) => { + stream.on("close", (code: number | null) => { + this.exitCode = code ?? null; + this.closed = true; + for (const w of this.waiters.splice(0)) { + w({ done: true, value: undefined }); + } + resolve(new CommandFinished(this.exitCode, this._stdout, this._stderr)); + }); + stream.on("error", (err: Error) => { + this.closed = true; + for (const w of this.waiters.splice(0)) { + w({ done: true, value: undefined }); + } + reject(new SandboxError("Command stream error.", err)); + }); + }); + // Keep an unawaited stream error from surfacing as an unhandled rejection. + this.done.catch(() => {}); + } + + /** Async-iterate stdout and stderr chunks as they arrive. */ + logs(): AsyncIterableIterator { + const self = this; + return { + [Symbol.asyncIterator]() { + return this; + }, + next(): Promise> { + const buffered = self.queue.shift(); + if (buffered) return Promise.resolve({ done: false, value: buffered }); + if (self.closed) + return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve) => self.waiters.push(resolve)); + }, + }; + } + + async wait(): Promise { + return this.done; + } + + /** Collect the full stdout after the command exits. */ + async stdout(): Promise { + await this.done; + return this._stdout; + } + + /** Collect the full stderr after the command exits. */ + async stderr(): Promise { + await this.done; + return this._stderr; + } + + /** Send a signal to the running process. Defaults to TERM. */ + kill(signal = "TERM"): void { + this.stream.signal(signal); + } + + private push(stream: "stdout" | "stderr", data: string): void { + if (stream === "stdout") this._stdout += data; + else this._stderr += data; + const waiter = this.waiters.shift(); + if (waiter) waiter({ done: false, value: { stream, data } }); + else this.queue.push({ stream, data }); + } +} diff --git a/packages/sandbox/src/errors.ts b/packages/sandbox/src/errors.ts new file mode 100644 index 0000000..f4dad11 --- /dev/null +++ b/packages/sandbox/src/errors.ts @@ -0,0 +1,6 @@ +export class SandboxError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause !== undefined ? { cause } : undefined); + this.name = "SandboxError"; + } +} diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts new file mode 100644 index 0000000..c9886d3 --- /dev/null +++ b/packages/sandbox/src/index.ts @@ -0,0 +1,12 @@ +export { Command, CommandFinished, type LogChunk } from "./command.ts"; +export { SandboxError } from "./errors.ts"; +export { Sandbox } from "./sandbox.ts"; +export type { + CreateOptions, + FileToWrite, + GetOptions, + RunCommandOptions, + SandboxAuth, + SandboxHandle, + SandboxImage, +} from "./types.ts"; diff --git a/packages/sandbox/src/integration.test.ts b/packages/sandbox/src/integration.test.ts new file mode 100644 index 0000000..2393776 --- /dev/null +++ b/packages/sandbox/src/integration.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { Sandbox } from "./index.ts"; + +// Live test against a real bunny.net account. Skipped unless both +// BUNNYNET_API_KEY and SANDBOX_INTEGRATION=1 are set, so it never runs +// during normal `bun test` or in CI. It provisions a real app, connects +// over SSH, exercises the SDK, then deletes the app. +const RUN = + !!process.env.BUNNYNET_API_KEY && process.env.SANDBOX_INTEGRATION === "1"; + +const suite = RUN ? describe : describe.skip; +const TIMEOUT_MS = 240_000; +const region = process.env.SANDBOX_REGION ?? "AMS"; + +suite("sandbox integration", () => { + test( + "create, exec, file IO, detached logs, delete", + async () => { + const sandbox = await Sandbox.create({ + name: `it-${Date.now().toString(36)}`, + region, + }); + + try { + // Capture stdout from a blocking command. + const echo = await sandbox.runCommand("echo", ["hello-sandbox"]); + expect(echo.exitCode).toBe(0); + expect((await echo.stdout()).trim()).toBe("hello-sandbox"); + + // Buffer a file in and read it back. + await sandbox.writeFiles([ + { path: "note.txt", content: "from the integration test" }, + ]); + const buf = await sandbox.readFile("note.txt"); + expect(buf?.toString()).toBe("from the integration test"); + + // Missing files resolve to null. + expect(await sandbox.readFile("does-not-exist.txt")).toBeNull(); + + // Stream output from a detached command. + const cmd = await sandbox.runCommand({ + cmd: "sh", + args: ["-c", "echo one; echo two"], + detached: true, + }); + let output = ""; + for await (const chunk of cmd.logs()) output += chunk.data; + const finished = await cmd.wait(); + expect(finished.exitCode).toBe(0); + expect(output).toContain("one"); + expect(output).toContain("two"); + } finally { + await sandbox.delete().catch(() => {}); + } + }, + TIMEOUT_MS, + ); +}); diff --git a/packages/sandbox/src/provision.ts b/packages/sandbox/src/provision.ts new file mode 100644 index 0000000..f0c9582 --- /dev/null +++ b/packages/sandbox/src/provision.ts @@ -0,0 +1,294 @@ +import { + ApiError, + type ClientOptions, + createMcClient, +} from "@bunny.net/openapi-client"; +import { SandboxError } from "./errors.ts"; +import type { SandboxAuth, SandboxImage } from "./types.ts"; + +export const WORKPLACE = "/workplace"; +export const SSH_PORT = 8023; + +const POLL_INTERVAL_MS = 3000; +const PROVISION_TIMEOUT_MS = 120_000; +const PUBLIC_HOST_TIMEOUT_MS = 60_000; +const REGISTRY_RETRIES = 3; +const REGISTRY_RETRY_DELAY_MS = 4000; + +type McClient = ReturnType; + +interface AppContainer { + id?: string; + environmentVariables?: Array<{ name?: string; value?: string }>; + endpoints?: Array<{ type?: string; publicHost?: string }>; +} + +interface App { + id?: string; + status?: string; + containerTemplates?: AppContainer[]; +} + +export function resolveApiKey(auth: SandboxAuth): string { + const apiKey = auth.apiKey ?? process.env.BUNNYNET_API_KEY; + if (!apiKey) { + throw new SandboxError( + "No API key. Pass { apiKey } or set BUNNYNET_API_KEY.", + ); + } + return apiKey; +} + +export function mcClient(auth: SandboxAuth): McClient { + const options: ClientOptions = { + apiKey: resolveApiKey(auth), + baseUrl: auth.apiUrl, + verbose: auth.verbose, + userAgent: "bunny-sandbox-sdk", + onDebug: auth.onDebug, + }; + return createMcClient(options); +} + +export function extractAnycastHost(app: App): string | null { + for (const ct of app.containerTemplates ?? []) { + for (const ep of ct.endpoints ?? []) { + if (ep.type === "anycast" && ep.publicHost) return ep.publicHost; + } + } + return null; +} + +export function extractAgentToken(app: App): string | null { + for (const ct of app.containerTemplates ?? []) { + for (const env of ct.environmentVariables ?? []) { + if (env.name === "AGENT_TOKEN" && env.value) return env.value; + } + } + return null; +} + +export function firstContainerId(app: App): string | null { + return app.containerTemplates?.[0]?.id ?? null; +} + +/** Split a host:port string into a host and a port, defaulting to SSH_PORT. */ +export function splitHost(host: string): { host: string; port: number } { + if (!host.includes(":")) return { host, port: SSH_PORT }; + const [h, p] = host.split(":") as [string, string]; + return { host: h, port: Number(p) }; +} + +export async function getApp(client: McClient, appId: string): Promise { + const { data, error } = await client.GET("/apps/{appId}", { + params: { path: { appId } }, + }); + if (error) throw new SandboxError(`Failed to fetch app: ${str(error)}`); + return data as App; +} + +export async function deleteApp( + client: McClient, + appId: string, +): Promise { + const { error } = await client.DELETE("/apps/{appId}", { + params: { path: { appId } }, + }); + if (error) throw new SandboxError(`Failed to delete app: ${str(error)}`); +} + +export async function createApp( + client: McClient, + params: { + name: string; + region: string; + agentToken: string; + volumeSize: number; + env: Record; + image: SandboxImage; + }, +): Promise { + const environmentVariables = [ + { name: "AGENT_TOKEN", value: params.agentToken }, + ...Object.entries(params.env).map(([name, value]) => ({ name, value })), + ]; + + const body = { + name: params.name, + runtimeType: "shared", + autoScaling: { min: 1, max: 1 }, + regionSettings: { + allowedRegionIds: [params.region], + requiredRegionIds: [params.region], + }, + volumes: [{ name: "workplace", size: params.volumeSize }], + containerTemplates: [ + { + name: "agent", + imageRegistryId: params.image.registryId, + imageNamespace: params.image.namespace, + imageName: params.image.name, + imageTag: params.image.tag, + // A pinned digest lets MC skip its flaky create-time registry lookup. + ...(params.image.digest ? { imageDigest: params.image.digest } : {}), + imagePullPolicy: "ifNotPresent", + environmentVariables, + volumeMounts: [{ name: "workplace", mountPath: WORKPLACE }], + endpoints: [ + { + displayName: "ssh", + anycast: { + type: "ipv4", + portMappings: [ + { + containerPort: SSH_PORT, + exposedPort: SSH_PORT, + protocols: ["Tcp"], + }, + ], + }, + }, + ], + }, + ], + }; + + const data = await withRegistryRetry(async () => { + const res = await (client as any).POST("/apps", { body }); + if (res.error || !res.data) { + throw new SandboxError(`Failed to create sandbox: ${str(res.error)}`); + } + return res.data as App; + }); + + const appId = data.id; + if (!appId) throw new SandboxError("Create returned no app ID."); + return appId; +} + +/** Retry the transient MC registry error, which asks the caller to try again. */ +async function withRegistryRetry(fn: () => Promise): Promise { + for (let attempt = 1; attempt <= REGISTRY_RETRIES; attempt++) { + try { + return await fn(); + } catch (err) { + if (!isRegistryError(err) || attempt === REGISTRY_RETRIES) throw err; + await sleep(REGISTRY_RETRY_DELAY_MS * attempt); + } + } + // Unreachable: the final attempt always rethrows above. + throw new SandboxError("Registry retry exhausted."); +} + +/** Detect the catch-all MC registry error so it can be retried. */ +function isRegistryError(err: unknown): boolean { + const message = + err instanceof ApiError || err instanceof SandboxError + ? err.message + : String(err); + return message.toLowerCase().includes("container registry"); +} + +export async function waitForSshHost( + client: McClient, + appId: string, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + PROVISION_TIMEOUT_MS; + await sleep(POLL_INTERVAL_MS, signal); + while (Date.now() < deadline) { + signal?.throwIfAborted(); + const app = await getApp(client, appId); + if (app.status === "failing" || app.status === "suspended") { + throw new SandboxError(`Sandbox entered terminal state: ${app.status}`); + } + const host = extractAnycastHost(app); + if (host) return host; + await sleep(POLL_INTERVAL_MS, signal); + } + throw new SandboxError( + `SSH endpoint was not assigned within ${PROVISION_TIMEOUT_MS / 1000}s`, + ); +} + +export async function addCdnEndpoint( + client: McClient, + appId: string, + containerId: string, + port: number, + label?: string, +): Promise { + const displayName = label ?? `port-${port}`; + // Reuse an endpoint of the same name so retries after a slow host assignment do not conflict. + const existing = await findEndpointId(client, appId, displayName); + if (existing) return existing; + + const { data, error } = await (client as any).POST( + "/apps/{appId}/containers/{containerId}/endpoints", + { + params: { path: { appId, containerId } }, + body: { + displayName, + cdn: { + isSslEnabled: false, + portMappings: [{ containerPort: port, protocols: ["Tcp"] }], + }, + }, + }, + ); + if (error) throw new SandboxError(`Failed to expose port: ${str(error)}`); + const id = (data as { id?: string })?.id; + if (!id) throw new SandboxError("Endpoint creation returned no ID."); + return id; +} + +/** Poll for the endpoint's public host. Returns null if it is not assigned within the window. */ +export async function waitForPublicHost( + client: McClient, + appId: string, + endpointId: string, +): Promise { + const deadline = Date.now() + PUBLIC_HOST_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(2000); + const app = await getApp(client, appId); + if (app.status === "failing" || app.status === "suspended") { + throw new SandboxError(`Sandbox entered terminal state: ${app.status}`); + } + const { data } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId } }, + }); + const items = (data?.items ?? []) as Array<{ + id?: string; + publicHost?: string; + }>; + const found = items.find((e) => e.id === endpointId); + if (found?.publicHost) return found.publicHost; + } + return null; +} + +/** Find an existing endpoint by display name, returning its ID or null. */ +async function findEndpointId( + client: McClient, + appId: string, + displayName: string, +): Promise { + const { data } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId } }, + }); + const items = (data?.items ?? []) as Array<{ + id?: string; + displayName?: string; + }>; + return items.find((e) => e.displayName === displayName)?.id ?? null; +} + +function str(error: unknown): string { + return typeof error === "string" ? error : JSON.stringify(error); +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/sandbox/src/sandbox.test.ts b/packages/sandbox/src/sandbox.test.ts new file mode 100644 index 0000000..c3b6220 --- /dev/null +++ b/packages/sandbox/src/sandbox.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + extractAgentToken, + extractAnycastHost, + firstContainerId, + splitHost, +} from "./provision.ts"; +import { buildRemoteCommand, resolvePath, shellQuote } from "./sandbox.ts"; + +describe("buildRemoteCommand", () => { + test("defaults to the workplace and quotes the command", () => { + expect(buildRemoteCommand({ cmd: "ls", args: ["-la"] })).toBe( + "cd '/workplace' && 'ls' '-la'", + ); + }); + + test("honors cwd, sudo, and env", () => { + expect( + buildRemoteCommand({ + cmd: "node", + args: ["app.js"], + cwd: "/srv", + sudo: true, + env: { NODE_ENV: "production" }, + }), + ).toBe("cd '/srv' && sudo NODE_ENV='production' 'node' 'app.js'"); + }); + + test("rejects env names with shell metacharacters", () => { + expect(() => + buildRemoteCommand({ cmd: "ls", env: { "x; rm -rf /": "1" } }), + ).toThrow("Invalid environment variable name"); + }); +}); + +describe("resolvePath", () => { + test("keeps absolute paths", () => { + expect(resolvePath("/etc/hosts")).toBe("/etc/hosts"); + }); + test("resolves relative paths against the workplace", () => { + expect(resolvePath("src/index.ts")).toBe("/workplace/src/index.ts"); + }); +}); + +describe("shellQuote", () => { + test("escapes embedded single quotes", () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); +}); + +describe("splitHost", () => { + test("defaults to the SSH port", () => { + expect(splitHost("1.2.3.4")).toEqual({ host: "1.2.3.4", port: 8023 }); + }); + test("parses an explicit port", () => { + expect(splitHost("1.2.3.4:2222")).toEqual({ host: "1.2.3.4", port: 2222 }); + }); +}); + +describe("app extraction", () => { + const app = { + containerTemplates: [ + { + id: "ct-1", + environmentVariables: [{ name: "AGENT_TOKEN", value: "secret" }], + endpoints: [{ type: "anycast", publicHost: "1.2.3.4:8023" }], + }, + ], + }; + + test("reads the anycast host", () => { + expect(extractAnycastHost(app)).toBe("1.2.3.4:8023"); + }); + test("recovers the agent token", () => { + expect(extractAgentToken(app)).toBe("secret"); + }); + test("returns the first container id", () => { + expect(firstContainerId(app)).toBe("ct-1"); + }); + test("returns null when fields are absent", () => { + expect(extractAnycastHost({})).toBeNull(); + expect(extractAgentToken({})).toBeNull(); + expect(firstContainerId({})).toBeNull(); + }); +}); diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts new file mode 100644 index 0000000..a44edae --- /dev/null +++ b/packages/sandbox/src/sandbox.ts @@ -0,0 +1,296 @@ +import { randomBytes } from "node:crypto"; +import type { createMcClient } from "@bunny.net/openapi-client"; +import { Command, CommandFinished } from "./command.ts"; +import { SandboxError } from "./errors.ts"; +import { + addCdnEndpoint, + createApp, + deleteApp, + extractAgentToken, + extractAnycastHost, + firstContainerId, + getApp, + mcClient, + splitHost, + WORKPLACE, + waitForPublicHost, + waitForSshHost, +} from "./provision.ts"; +import { SshTransport } from "./transport.ts"; +import type { + CreateOptions, + FileToWrite, + GetOptions, + RunCommandOptions, + SandboxAuth, + SandboxHandle, +} from "./types.ts"; + +type McClient = ReturnType; + +const DEFAULT_REGION = "AMS"; +const DEFAULT_VOLUME_GB = 10; +const SSH_REACHABLE_TIMEOUT_MS = 120_000; +const DEFAULT_IMAGE = { + registryId: "1156", + namespace: "bunnyway", + name: "sandbox-agent", + tag: "latest", +} as const; + +/** Programmatic handle to a bunny.net sandbox backed by a Magic Containers app. */ +export class Sandbox { + readonly appId: string; + readonly name: string; + private readonly agentToken: string; + private readonly sshHost: string; + private readonly ports: Map; + private readonly clientFactory: () => McClient; + private clientInstance: McClient | null = null; + private readonly transport: SshTransport; + + private constructor( + handle: SandboxHandle, + clientFactory: () => McClient, + transport: SshTransport, + ) { + this.appId = handle.appId; + this.name = handle.name; + this.agentToken = handle.agentToken; + this.sshHost = handle.sshHost; + this.ports = new Map( + Object.entries(handle.ports ?? {}).map(([p, h]) => [Number(p), h]), + ); + this.clientFactory = clientFactory; + this.transport = transport; + } + + // Resolve the MC client lazily so SSH-only reconnects via fromHandle need no API key. + private get client(): McClient { + this.clientInstance ??= this.clientFactory(); + return this.clientInstance; + } + + /** Provision a new sandbox and wait until it accepts connections. */ + static async create(options: CreateOptions = {}): Promise { + const client = mcClient(options); + const name = options.name ?? generateName(); + const agentToken = generateToken(); + + const appId = await createApp(client, { + name, + region: options.region ?? DEFAULT_REGION, + agentToken, + volumeSize: options.volumeSize ?? DEFAULT_VOLUME_GB, + env: options.env ?? {}, + image: options.image ?? DEFAULT_IMAGE, + }); + + let sshHost: string; + try { + sshHost = await waitForSshHost(client, appId, options.signal); + } catch (err) { + await deleteApp(client, appId).catch(() => {}); + throw err; + } + + const transport = transportFor(sshHost, agentToken); + try { + await transport.waitUntilReachable( + SSH_REACHABLE_TIMEOUT_MS, + options.signal, + ); + } catch (err) { + await deleteApp(client, appId).catch(() => {}); + throw err; + } + + const sandbox = new Sandbox( + { appId, name, agentToken, sshHost }, + () => client, + transport, + ); + + try { + for (const port of options.ports ?? []) { + await sandbox.exposePort(port); + } + } catch (err) { + await deleteApp(client, appId).catch(() => {}); + throw err; + } + return sandbox; + } + + /** Retrieve an existing sandbox by app ID, recovering its connection details. */ + static async get(options: GetOptions): Promise { + const client = mcClient(options); + const app = await getApp(client, options.appId); + + const agentToken = extractAgentToken(app); + const sshHost = extractAnycastHost(app); + if (!agentToken || !sshHost) { + throw new SandboxError( + "Could not recover sandbox credentials from the app.", + ); + } + const name = (app as { name?: string }).name ?? options.appId; + + return new Sandbox( + { appId: options.appId, name, agentToken, sshHost }, + () => client, + transportFor(sshHost, agentToken), + ); + } + + /** Rebuild a sandbox from a serialized handle without an API round trip. */ + static fromHandle(handle: SandboxHandle, auth: SandboxAuth = {}): Sandbox { + return new Sandbox( + handle, + () => mcClient(auth), + transportFor(handle.sshHost, handle.agentToken), + ); + } + + /** Run a command, blocking for the result unless detached is set. */ + async runCommand(command: string, args?: string[]): Promise; + async runCommand(command: RunCommandOptions): Promise; + async runCommand( + command: string | RunCommandOptions, + args: string[] = [], + ): Promise { + const opts: RunCommandOptions = + typeof command === "string" ? { cmd: command, args } : command; + const remote = buildRemoteCommand(opts); + + if (opts.detached) { + return new Command(await this.transport.execStream(remote)); + } + const { stdout, stderr, exitCode } = await this.transport.exec(remote); + return new CommandFinished(exitCode, stdout, stderr); + } + + /** Upload one or more files, creating parent directories as needed. */ + async writeFiles(files: FileToWrite[]): Promise { + for (const file of files) { + const path = resolvePath(file.path); + const dir = path.slice(0, path.lastIndexOf("/")); + if (dir) await this.mkDir(dir); + const content = + typeof file.content === "string" + ? Buffer.from(file.content) + : file.content; + await this.transport.writeFile(path, content, file.mode); + } + } + + /** Read a file into a Buffer, or null when it does not exist. */ + async readFile(path: string): Promise { + return this.transport.readFile(resolvePath(path)); + } + + async mkDir(path: string): Promise { + const { exitCode, stderr } = await this.transport.exec( + `mkdir -p ${shellQuote(resolvePath(path))}`, + ); + if (exitCode !== 0) { + throw new SandboxError(`Failed to create directory: ${stderr.trim()}`); + } + } + + /** Expose a container port as a public CDN endpoint and return its URL. */ + async exposePort(port: number, label?: string): Promise { + const app = await getApp(this.client, this.appId); + const containerId = firstContainerId(app); + if (!containerId) { + throw new SandboxError("Could not find a container to expose the port."); + } + const endpointId = await addCdnEndpoint( + this.client, + this.appId, + containerId, + port, + label, + ); + const host = await waitForPublicHost(this.client, this.appId, endpointId); + if (!host) { + throw new SandboxError( + `Endpoint "${label ?? `port-${port}`}" was created but its public host is still provisioning. Re-run to fetch the URL once it is ready.`, + ); + } + this.ports.set(port, host); + return `https://${host}`; + } + + /** Return the public URL for a previously exposed port. */ + domain(port: number): string { + const host = this.ports.get(port); + if (!host) { + throw new SandboxError( + `Port ${port} is not exposed. Call exposePort(${port}) first.`, + ); + } + return `https://${host}`; + } + + /** Permanently delete the sandbox and its backing app. */ + async delete(): Promise { + this.transport.close(); + await deleteApp(this.client, this.appId); + } + + /** Close the SSH connection without deleting the sandbox. */ + disconnect(): void { + this.transport.close(); + } + + /** Serialize the sandbox so another process can reconnect via fromHandle. */ + toHandle(): SandboxHandle { + return { + appId: this.appId, + name: this.name, + agentToken: this.agentToken, + sshHost: this.sshHost, + ports: Object.fromEntries(this.ports), + }; + } +} + +function transportFor(sshHost: string, password: string): SshTransport { + const { host, port } = splitHost(sshHost); + return new SshTransport({ host, port, password }); +} + +const ENV_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export function buildRemoteCommand(opts: RunCommandOptions): string { + const parts: string[] = [`cd ${shellQuote(opts.cwd ?? WORKPLACE)} &&`]; + if (opts.sudo) parts.push("sudo"); + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (!ENV_KEY_PATTERN.test(key)) { + throw new SandboxError(`Invalid environment variable name: ${key}`); + } + parts.push(`${key}=${shellQuote(value)}`); + } + parts.push(shellQuote(opts.cmd)); + for (const arg of opts.args ?? []) parts.push(shellQuote(arg)); + return parts.join(" "); +} + +/** Resolve a sandbox path, defaulting relative paths to the workplace. */ +export function resolvePath(path: string): string { + return path.startsWith("/") ? path : `${WORKPLACE}/${path}`; +} + +/** Single-quote a token for safe use in a remote shell command. */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function generateToken(): string { + return randomBytes(32).toString("base64url"); +} + +function generateName(): string { + return `sandbox-${randomBytes(4).toString("hex")}`; +} diff --git a/packages/sandbox/src/transport.ts b/packages/sandbox/src/transport.ts new file mode 100644 index 0000000..933fb88 --- /dev/null +++ b/packages/sandbox/src/transport.ts @@ -0,0 +1,173 @@ +import { + Client, + type ClientChannel, + type ConnectConfig, + type SFTPWrapper, +} from "ssh2"; +import { SandboxError } from "./errors.ts"; + +export interface TransportConfig { + host: string; + port: number; + /** Agent token used as the root password. */ + password: string; + username?: string; +} + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +// ssh2 reports a missing SFTP file as the numeric status NO_SUCH_FILE (2), not a Node ENOENT string. +const SFTP_NO_SUCH_FILE = 2; +function isMissingFileError(err: unknown): boolean { + const code = (err as { code?: unknown }).code; + return ( + code === "ENOENT" || code === "NO_SUCH_FILE" || code === SFTP_NO_SUCH_FILE + ); +} + +/** Pure-JS SSH/SFTP transport over a single reused connection. */ +export class SshTransport { + private conn: Client | null = null; + private connecting: Promise | null = null; + private sftpChannel: Promise | null = null; + + constructor(private readonly config: TransportConfig) {} + + /** Connect, retrying until the SSH server accepts connections or timeout. */ + async waitUntilReachable( + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + const deadline = Date.now() + timeoutMs; + let lastErr: unknown; + while (Date.now() < deadline) { + signal?.throwIfAborted(); + try { + await this.ready(); + return; + } catch (err) { + lastErr = err; + await new Promise((r) => setTimeout(r, 3000)); + } + } + throw new SandboxError("Sandbox SSH did not become reachable.", lastErr); + } + + /** Run a command to completion and collect its output. */ + async exec(command: string): Promise { + const stream = await this.execStream(command); + let stdout = ""; + let stderr = ""; + stream.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + stream.stderr.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + return new Promise((resolve, reject) => { + stream.on("error", reject); + stream.on("close", (code: number | null) => { + resolve({ stdout, stderr, exitCode: code }); + }); + }); + } + + /** Start a command and return its live channel for streaming. */ + async execStream(command: string): Promise { + const conn = await this.ready(); + return new Promise((resolve, reject) => { + conn.exec(command, (err, stream) => { + if (err) reject(new SandboxError("Command failed to start.", err)); + else resolve(stream); + }); + }); + } + + async writeFile(path: string, content: Buffer, mode?: number): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.writeFile(path, content, mode ? { mode } : {}, (err) => { + if (err) reject(new SandboxError(`Failed to write ${path}.`, err)); + else resolve(); + }); + }); + } + + /** Read a file into a Buffer, or null when it does not exist. */ + async readFile(path: string): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.readFile(path, (err, data) => { + if (!err) return resolve(data); + if (isMissingFileError(err)) return resolve(null); + reject(new SandboxError(`Failed to read ${path}.`, err)); + }); + }); + } + + close(): void { + this.conn?.end(); + this.conn = null; + this.connecting = null; + this.sftpChannel = null; + } + + /** Open the SFTP subsystem once and reuse the channel across operations. */ + private sftp(): Promise { + if (this.sftpChannel) return this.sftpChannel; + this.sftpChannel = this.ready().then( + (conn) => + new Promise((resolve, reject) => { + conn.sftp((err, sftp) => { + if (err) { + this.sftpChannel = null; + reject(new SandboxError("Failed to open SFTP.", err)); + return; + } + sftp.on("close", () => { + this.sftpChannel = null; + }); + resolve(sftp); + }); + }), + ); + return this.sftpChannel; + } + + private ready(): Promise { + if (this.conn) return Promise.resolve(this.conn); + if (this.connecting) return this.connecting; + + const config: ConnectConfig = { + host: this.config.host, + port: this.config.port, + username: this.config.username ?? "root", + password: this.config.password, + readyTimeout: 15_000, + }; + + this.connecting = new Promise((resolve, reject) => { + const conn = new Client(); + conn + .on("ready", () => { + this.conn = conn; + this.connecting = null; + resolve(conn); + }) + .on("error", (err) => { + this.connecting = null; + reject(new SandboxError("SSH connection failed.", err)); + }) + .on("close", () => { + this.conn = null; + this.sftpChannel = null; + }) + .connect(config); + }); + return this.connecting; + } +} diff --git a/packages/sandbox/src/types.ts b/packages/sandbox/src/types.ts new file mode 100644 index 0000000..02bf4a0 --- /dev/null +++ b/packages/sandbox/src/types.ts @@ -0,0 +1,70 @@ +export interface SandboxAuth { + /** bunny.net API key. Falls back to BUNNYNET_API_KEY when omitted. */ + apiKey?: string; + /** Override the Magic Containers API base URL. */ + apiUrl?: string; + /** Emit request/response debug lines to onDebug. */ + verbose?: boolean; + /** Debug logger callback, used when verbose is true. */ + onDebug?: (msg: string) => void; +} + +export interface CreateOptions extends SandboxAuth { + /** Unique app name. A random name is generated when omitted. */ + name?: string; + /** Region ID to deploy in (e.g. AMS, NY, LA). Defaults to AMS. */ + region?: string; + /** Container ports to expose as public CDN endpoints at creation time. */ + ports?: number[]; + /** Default environment variables for the container. */ + env?: Record; + /** Persistent volume size in GB. Defaults to 10. */ + volumeSize?: number; + /** Override the container image. Defaults to the bunny sandbox-agent image. */ + image?: SandboxImage; + /** Cancel provisioning. */ + signal?: AbortSignal; +} + +export interface GetOptions extends SandboxAuth { + appId: string; +} + +export interface SandboxImage { + registryId: string; + namespace: string; + name: string; + tag: string; + /** Pinned sha256 digest. Lets MC skip its flaky create-time registry lookup. */ + digest?: string; +} + +/** Serializable handle that round-trips a sandbox across processes. */ +export interface SandboxHandle { + appId: string; + name: string; + agentToken: string; + sshHost: string; + /** Exposed port to public host mappings discovered so far. */ + ports?: Record; +} + +export interface RunCommandOptions { + cmd: string; + args?: string[]; + /** Working directory. Defaults to the sandbox workplace. */ + cwd?: string; + /** Extra environment variables for this command only. */ + env?: Record; + sudo?: boolean; + /** Return immediately with a live Command instead of blocking. */ + detached?: boolean; +} + +export interface FileToWrite { + /** Path inside the sandbox. Relative paths resolve against the workplace. */ + path: string; + content: Buffer | string; + /** Unix file mode in octal, e.g. 0o755 for an executable. */ + mode?: number; +} diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/packages/sandbox/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +}