@blaxel/core 0.2.67-preview.88 → 0.2.67-preview.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/common/settings.js +2 -2
- package/dist/cjs/image/image.browser.js +84 -0
- package/dist/cjs/image/image.js +567 -0
- package/dist/cjs/image/image.test.js +545 -0
- package/dist/cjs/image/index.js +7 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/types/image/image.browser.d.ts +59 -0
- package/dist/cjs/types/image/image.d.ts +202 -0
- package/dist/cjs/types/image/image.test.d.ts +4 -0
- package/dist/cjs/types/image/index.d.ts +1 -0
- package/dist/cjs/types/index.d.ts +1 -0
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/common/settings.js +2 -2
- package/dist/cjs-browser/image/image.js +84 -0
- package/dist/cjs-browser/image/image.test.js +545 -0
- package/dist/cjs-browser/image/index.js +7 -0
- package/dist/cjs-browser/index.js +1 -0
- package/dist/cjs-browser/types/image/image.browser.d.ts +59 -0
- package/dist/cjs-browser/types/image/image.d.ts +202 -0
- package/dist/cjs-browser/types/image/image.test.d.ts +4 -0
- package/dist/cjs-browser/types/image/index.d.ts +1 -0
- package/dist/cjs-browser/types/index.d.ts +1 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/common/settings.js +2 -2
- package/dist/esm/image/image.browser.js +80 -0
- package/dist/esm/image/image.js +560 -0
- package/dist/esm/image/image.test.js +543 -0
- package/dist/esm/image/index.js +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/common/settings.js +2 -2
- package/dist/esm-browser/image/image.js +80 -0
- package/dist/esm-browser/image/image.test.js +543 -0
- package/dist/esm-browser/image/index.js +1 -0
- package/dist/esm-browser/index.js +1 -0
- package/package.json +4 -1
|
@@ -3,8 +3,8 @@ import { authentication } from "../authentication/index.js";
|
|
|
3
3
|
import { env } from "../common/env.js";
|
|
4
4
|
import { fs, os, path } from "../common/node.js";
|
|
5
5
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
6
|
-
const BUILD_VERSION = "0.2.67-preview.
|
|
7
|
-
const BUILD_COMMIT = "
|
|
6
|
+
const BUILD_VERSION = "0.2.67-preview.90";
|
|
7
|
+
const BUILD_COMMIT = "0b5a3b81059ce9031873088b36eed87a0c589f65";
|
|
8
8
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
9
9
|
// Cache for config.yaml tracking value
|
|
10
10
|
let configTrackingValue = null;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const SANDBOX_API_IMAGE = "ghcr.io/blaxel-ai/sandbox";
|
|
2
|
+
export const SANDBOX_API_PATH = "/usr/local/bin/sandbox-api";
|
|
3
|
+
function throwBrowserError() {
|
|
4
|
+
throw new Error("ImageInstance is only available in Node.js environments. " +
|
|
5
|
+
"Image building requires file system operations that are not supported in browsers.");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A fluent builder for creating sandbox images programmatically.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: This class is only available in Node.js environments.
|
|
11
|
+
* In browser environments, all methods will throw an error.
|
|
12
|
+
*/
|
|
13
|
+
export class ImageInstance {
|
|
14
|
+
_context;
|
|
15
|
+
constructor(_context) {
|
|
16
|
+
this._context = _context;
|
|
17
|
+
throwBrowserError();
|
|
18
|
+
}
|
|
19
|
+
static fromRegistry(_tag) {
|
|
20
|
+
throwBrowserError();
|
|
21
|
+
}
|
|
22
|
+
workdir(_path) {
|
|
23
|
+
throwBrowserError();
|
|
24
|
+
}
|
|
25
|
+
runCommands(..._commands) {
|
|
26
|
+
throwBrowserError();
|
|
27
|
+
}
|
|
28
|
+
env(_variables) {
|
|
29
|
+
throwBrowserError();
|
|
30
|
+
}
|
|
31
|
+
copy(_source, _destination) {
|
|
32
|
+
throwBrowserError();
|
|
33
|
+
}
|
|
34
|
+
addLocalFile(_sourcePath, _destination, _contextName) {
|
|
35
|
+
throwBrowserError();
|
|
36
|
+
}
|
|
37
|
+
addLocalDir(_sourcePath, _destination, _contextName) {
|
|
38
|
+
throwBrowserError();
|
|
39
|
+
}
|
|
40
|
+
expose(..._ports) {
|
|
41
|
+
throwBrowserError();
|
|
42
|
+
}
|
|
43
|
+
entrypoint(..._args) {
|
|
44
|
+
throwBrowserError();
|
|
45
|
+
}
|
|
46
|
+
user(_user) {
|
|
47
|
+
throwBrowserError();
|
|
48
|
+
}
|
|
49
|
+
label(_labels) {
|
|
50
|
+
throwBrowserError();
|
|
51
|
+
}
|
|
52
|
+
arg(_name, _defaultValue) {
|
|
53
|
+
throwBrowserError();
|
|
54
|
+
}
|
|
55
|
+
get dockerfile() {
|
|
56
|
+
throw new Error("ImageInstance is only available in Node.js environments. " +
|
|
57
|
+
"Image building requires file system operations that are not supported in browsers.");
|
|
58
|
+
}
|
|
59
|
+
get hash() {
|
|
60
|
+
throw new Error("ImageInstance is only available in Node.js environments. " +
|
|
61
|
+
"Image building requires file system operations that are not supported in browsers.");
|
|
62
|
+
}
|
|
63
|
+
get baseImage() {
|
|
64
|
+
throw new Error("ImageInstance is only available in Node.js environments. " +
|
|
65
|
+
"Image building requires file system operations that are not supported in browsers.");
|
|
66
|
+
}
|
|
67
|
+
write(_outputPath, _name) {
|
|
68
|
+
throwBrowserError();
|
|
69
|
+
}
|
|
70
|
+
writeTemp() {
|
|
71
|
+
throwBrowserError();
|
|
72
|
+
}
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
74
|
+
async build(_options) {
|
|
75
|
+
throwBrowserError();
|
|
76
|
+
}
|
|
77
|
+
buildSync(_options) {
|
|
78
|
+
throwBrowserError();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { DockerfileParser } from "dockerfile-ast";
|
|
2
|
+
import { crypto, fs, os, path } from "../common/node.js";
|
|
3
|
+
import { settings } from "../common/settings.js";
|
|
4
|
+
import archiver from "archiver";
|
|
5
|
+
function ensureNodeEnvironment() {
|
|
6
|
+
if (!fs || !os || !path || !crypto) {
|
|
7
|
+
throw new Error("Image is only available in Node.js environments. File system operations are not supported in browsers.");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export const SANDBOX_API_IMAGE = "ghcr.io/blaxel-ai/sandbox";
|
|
11
|
+
export const SANDBOX_API_PATH = "/usr/local/bin/sandbox-api";
|
|
12
|
+
function generateDockerfile(context) {
|
|
13
|
+
// Build the raw Dockerfile content
|
|
14
|
+
const lines = [`FROM ${context.baseImage}`];
|
|
15
|
+
lines.push(...context.instructions);
|
|
16
|
+
const rawContent = lines.join("\n") + "\n";
|
|
17
|
+
// Parse using dockerfile-ast to validate syntax
|
|
18
|
+
const dockerfile = DockerfileParser.parse(rawContent);
|
|
19
|
+
// Check for any parsing errors by validating instruction count matches
|
|
20
|
+
const instructions = dockerfile.getInstructions();
|
|
21
|
+
if (instructions.length !== lines.length) {
|
|
22
|
+
throw new Error("Invalid Dockerfile syntax: instruction count mismatch after parsing");
|
|
23
|
+
}
|
|
24
|
+
// Return raw content to preserve escaping in JSON-format instructions (ENTRYPOINT, CMD)
|
|
25
|
+
// The AST reconstruction via toString() doesn't properly preserve escape sequences
|
|
26
|
+
return rawContent;
|
|
27
|
+
}
|
|
28
|
+
function computeHash(context) {
|
|
29
|
+
ensureNodeEnvironment();
|
|
30
|
+
let content = generateDockerfile(context);
|
|
31
|
+
for (const localFile of context.localFiles) {
|
|
32
|
+
if (!fs.existsSync(localFile.sourcePath)) {
|
|
33
|
+
throw new Error(`Local file not found: ${localFile.sourcePath}. Cannot compute hash for missing files.`);
|
|
34
|
+
}
|
|
35
|
+
const stat = fs.statSync(localFile.sourcePath);
|
|
36
|
+
content += `\n${localFile.contextName}:${stat.mtimeMs}`;
|
|
37
|
+
}
|
|
38
|
+
return crypto.createHash("sha256").update(content).digest("hex").substring(0, 12);
|
|
39
|
+
}
|
|
40
|
+
function cloneContext(context) {
|
|
41
|
+
return {
|
|
42
|
+
baseImage: context.baseImage,
|
|
43
|
+
instructions: [...context.instructions],
|
|
44
|
+
localFiles: [...context.localFiles],
|
|
45
|
+
hasEntrypoint: context.hasEntrypoint,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function sleep(ms) {
|
|
49
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A fluent builder for creating sandbox images programmatically.
|
|
53
|
+
*
|
|
54
|
+
* Similar to Modal's Image class, allows chaining operations to build
|
|
55
|
+
* a custom image from a base image.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const image = ImageInstance.fromRegistry("python:3.11-slim")
|
|
60
|
+
* .runCommands("apt-get update && apt-get install -y git curl")
|
|
61
|
+
* .workdir("/app")
|
|
62
|
+
* .runCommands("pip install --upgrade pip")
|
|
63
|
+
* .env({ PYTHONUNBUFFERED: "1" });
|
|
64
|
+
*
|
|
65
|
+
* await image.build({
|
|
66
|
+
* name: "my-sandbox",
|
|
67
|
+
* memory: 4096,
|
|
68
|
+
* timeout: 900000,
|
|
69
|
+
* onStatusChange: console.log,
|
|
70
|
+
* sandboxVersion: "latest",
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export class ImageInstance {
|
|
75
|
+
_context;
|
|
76
|
+
constructor(context) {
|
|
77
|
+
this._context = context;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Create an image from a Docker registry image.
|
|
81
|
+
*
|
|
82
|
+
* @param tag - The image tag (e.g., "python:3.11-slim", "ubuntu:22.04")
|
|
83
|
+
* @returns A new Image instance
|
|
84
|
+
*/
|
|
85
|
+
static fromRegistry(tag) {
|
|
86
|
+
const context = {
|
|
87
|
+
baseImage: tag,
|
|
88
|
+
instructions: [],
|
|
89
|
+
localFiles: [],
|
|
90
|
+
hasEntrypoint: false,
|
|
91
|
+
};
|
|
92
|
+
return new ImageInstance(context);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Set the working directory for subsequent instructions.
|
|
96
|
+
*
|
|
97
|
+
* @param path - The working directory path inside the container
|
|
98
|
+
* @returns A new Image instance with the working directory set
|
|
99
|
+
*/
|
|
100
|
+
workdir(path) {
|
|
101
|
+
const newContext = cloneContext(this._context);
|
|
102
|
+
newContext.instructions.push(`WORKDIR ${path}`);
|
|
103
|
+
return new ImageInstance(newContext);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Run shell commands in the image.
|
|
107
|
+
*
|
|
108
|
+
* @param commands - One or more shell commands to run
|
|
109
|
+
* @returns A new Image instance with the commands added
|
|
110
|
+
*/
|
|
111
|
+
runCommands(...commands) {
|
|
112
|
+
const newContext = cloneContext(this._context);
|
|
113
|
+
for (const cmd of commands) {
|
|
114
|
+
newContext.instructions.push(`RUN ${cmd}`);
|
|
115
|
+
}
|
|
116
|
+
return new ImageInstance(newContext);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Set environment variables.
|
|
120
|
+
*
|
|
121
|
+
* @param variables - Environment variables as an object
|
|
122
|
+
* @returns A new Image instance with the environment variables set
|
|
123
|
+
*/
|
|
124
|
+
env(variables) {
|
|
125
|
+
const keys = Object.keys(variables);
|
|
126
|
+
if (keys.length === 0) {
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
const newContext = cloneContext(this._context);
|
|
130
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
131
|
+
newContext.instructions.push(`ENV ${key}="${value}"`);
|
|
132
|
+
}
|
|
133
|
+
return new ImageInstance(newContext);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Copy files or directories from the build context to the image.
|
|
137
|
+
*
|
|
138
|
+
* @param source - Source path (relative to build context)
|
|
139
|
+
* @param destination - Destination path in the image
|
|
140
|
+
* @returns A new Image instance with the copy instruction
|
|
141
|
+
*/
|
|
142
|
+
copy(source, destination) {
|
|
143
|
+
const newContext = cloneContext(this._context);
|
|
144
|
+
newContext.instructions.push(`COPY ${source} ${destination}`);
|
|
145
|
+
return new ImageInstance(newContext);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Add a local file to the build context and copy it to the image.
|
|
149
|
+
*
|
|
150
|
+
* @param sourcePath - Path to the local file
|
|
151
|
+
* @param destination - Destination path in the image
|
|
152
|
+
* @param contextName - Optional name for the file in the build context
|
|
153
|
+
* @returns A new Image instance with the file added
|
|
154
|
+
*/
|
|
155
|
+
addLocalFile(sourcePath, destination, contextName) {
|
|
156
|
+
ensureNodeEnvironment();
|
|
157
|
+
const source = path.resolve(sourcePath);
|
|
158
|
+
const name = contextName ?? path.basename(source);
|
|
159
|
+
const newContext = cloneContext(this._context);
|
|
160
|
+
newContext.localFiles.push({
|
|
161
|
+
sourcePath: source,
|
|
162
|
+
destinationPath: destination,
|
|
163
|
+
contextName: name,
|
|
164
|
+
});
|
|
165
|
+
newContext.instructions.push(`COPY ${name} ${destination}`);
|
|
166
|
+
return new ImageInstance(newContext);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Add a local directory to the build context and copy it to the image.
|
|
170
|
+
*
|
|
171
|
+
* @param sourcePath - Path to the local directory
|
|
172
|
+
* @param destination - Destination path in the image
|
|
173
|
+
* @param contextName - Optional name for the directory in the build context
|
|
174
|
+
* @returns A new Image instance with the directory added
|
|
175
|
+
*/
|
|
176
|
+
addLocalDir(sourcePath, destination, contextName) {
|
|
177
|
+
ensureNodeEnvironment();
|
|
178
|
+
const source = path.resolve(sourcePath);
|
|
179
|
+
const name = contextName ?? path.basename(source);
|
|
180
|
+
const newContext = cloneContext(this._context);
|
|
181
|
+
newContext.localFiles.push({
|
|
182
|
+
sourcePath: source,
|
|
183
|
+
destinationPath: destination,
|
|
184
|
+
contextName: name,
|
|
185
|
+
});
|
|
186
|
+
newContext.instructions.push(`COPY ${name} ${destination}`);
|
|
187
|
+
return new ImageInstance(newContext);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Expose ports.
|
|
191
|
+
*
|
|
192
|
+
* @param ports - Port numbers to expose
|
|
193
|
+
* @returns A new Image instance with the ports exposed
|
|
194
|
+
*/
|
|
195
|
+
expose(...ports) {
|
|
196
|
+
if (ports.length === 0) {
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
const newContext = cloneContext(this._context);
|
|
200
|
+
for (const port of ports) {
|
|
201
|
+
newContext.instructions.push(`EXPOSE ${port}`);
|
|
202
|
+
}
|
|
203
|
+
return new ImageInstance(newContext);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Set the entrypoint for the image.
|
|
207
|
+
*
|
|
208
|
+
* @param args - Entrypoint command and arguments
|
|
209
|
+
* @returns A new Image instance with the entrypoint set
|
|
210
|
+
*/
|
|
211
|
+
entrypoint(...args) {
|
|
212
|
+
if (args.length === 0) {
|
|
213
|
+
return this;
|
|
214
|
+
}
|
|
215
|
+
// Format as JSON array for exec form, using JSON.stringify to properly escape quotes and special characters
|
|
216
|
+
const argsJson = args.map((arg) => JSON.stringify(arg)).join(", ");
|
|
217
|
+
const newContext = cloneContext(this._context);
|
|
218
|
+
newContext.instructions.push(`ENTRYPOINT [${argsJson}]`);
|
|
219
|
+
newContext.hasEntrypoint = true;
|
|
220
|
+
return new ImageInstance(newContext);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Set the user for subsequent instructions.
|
|
224
|
+
*
|
|
225
|
+
* @param user - Username or UID
|
|
226
|
+
* @returns A new Image instance with the user set
|
|
227
|
+
*/
|
|
228
|
+
user(user) {
|
|
229
|
+
const newContext = cloneContext(this._context);
|
|
230
|
+
newContext.instructions.push(`USER ${user}`);
|
|
231
|
+
return new ImageInstance(newContext);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Add labels to the image.
|
|
235
|
+
*
|
|
236
|
+
* @param labels - Labels as an object
|
|
237
|
+
* @returns A new Image instance with the labels added
|
|
238
|
+
*/
|
|
239
|
+
label(labels) {
|
|
240
|
+
const keys = Object.keys(labels);
|
|
241
|
+
if (keys.length === 0) {
|
|
242
|
+
return this;
|
|
243
|
+
}
|
|
244
|
+
const newContext = cloneContext(this._context);
|
|
245
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
246
|
+
newContext.instructions.push(`LABEL ${key}="${value}"`);
|
|
247
|
+
}
|
|
248
|
+
return new ImageInstance(newContext);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Define a build argument.
|
|
252
|
+
*
|
|
253
|
+
* @param name - Argument name
|
|
254
|
+
* @param defaultValue - Optional default value
|
|
255
|
+
* @returns A new Image instance with the argument defined
|
|
256
|
+
*/
|
|
257
|
+
arg(name, defaultValue) {
|
|
258
|
+
const newContext = cloneContext(this._context);
|
|
259
|
+
if (defaultValue !== undefined) {
|
|
260
|
+
newContext.instructions.push(`ARG ${name}=${defaultValue}`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
newContext.instructions.push(`ARG ${name}`);
|
|
264
|
+
}
|
|
265
|
+
return new ImageInstance(newContext);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Get the generated Dockerfile content.
|
|
269
|
+
*/
|
|
270
|
+
get dockerfile() {
|
|
271
|
+
return generateDockerfile(this._context);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Get a hash of the image configuration.
|
|
275
|
+
*/
|
|
276
|
+
get hash() {
|
|
277
|
+
return computeHash(this._context);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Get the base image tag.
|
|
281
|
+
*/
|
|
282
|
+
get baseImage() {
|
|
283
|
+
return this._context.baseImage;
|
|
284
|
+
}
|
|
285
|
+
_hasSandboxApi() {
|
|
286
|
+
const dockerfile = generateDockerfile(this._context);
|
|
287
|
+
return dockerfile.includes("sandbox-api") || dockerfile.includes("blaxel-ai/sandbox");
|
|
288
|
+
}
|
|
289
|
+
_prepareForSandbox(sandboxVersion = "latest") {
|
|
290
|
+
const newContext = cloneContext(this._context);
|
|
291
|
+
// Add sandbox-api if not already present
|
|
292
|
+
if (!this._hasSandboxApi()) {
|
|
293
|
+
const sandboxImage = `${SANDBOX_API_IMAGE}:${sandboxVersion}`;
|
|
294
|
+
const copyInstruction = `COPY --from=${sandboxImage} /sandbox-api ${SANDBOX_API_PATH}`;
|
|
295
|
+
newContext.instructions.push(copyInstruction);
|
|
296
|
+
}
|
|
297
|
+
// Add default entrypoint if not set by user
|
|
298
|
+
if (!newContext.hasEntrypoint) {
|
|
299
|
+
newContext.instructions.push(`ENTRYPOINT ["${SANDBOX_API_PATH}"]`);
|
|
300
|
+
newContext.hasEntrypoint = true;
|
|
301
|
+
}
|
|
302
|
+
return new ImageInstance(newContext);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Write the image to a deployable folder structure.
|
|
306
|
+
*
|
|
307
|
+
* @param outputPath - Path to the output directory
|
|
308
|
+
* @param name - Optional name for the generated folder (defaults to hash-based name)
|
|
309
|
+
* @returns Path to the generated folder
|
|
310
|
+
*/
|
|
311
|
+
write(outputPath, name) {
|
|
312
|
+
ensureNodeEnvironment();
|
|
313
|
+
const outputDir = path.resolve(outputPath);
|
|
314
|
+
// Create folder name based on hash if not provided
|
|
315
|
+
if (!name) {
|
|
316
|
+
name = `image-${this.hash}`;
|
|
317
|
+
}
|
|
318
|
+
const buildDir = path.join(outputDir, name);
|
|
319
|
+
fs.mkdirSync(buildDir, { recursive: true });
|
|
320
|
+
// Generate Dockerfile
|
|
321
|
+
const dockerfilePath = path.join(buildDir, "Dockerfile");
|
|
322
|
+
fs.writeFileSync(dockerfilePath, generateDockerfile(this._context));
|
|
323
|
+
// Copy local files to build context
|
|
324
|
+
for (const localFile of this._context.localFiles) {
|
|
325
|
+
if (!fs.existsSync(localFile.sourcePath)) {
|
|
326
|
+
throw new Error(`Local file not found: ${localFile.sourcePath}`);
|
|
327
|
+
}
|
|
328
|
+
const dest = path.join(buildDir, localFile.contextName);
|
|
329
|
+
const stat = fs.statSync(localFile.sourcePath);
|
|
330
|
+
if (stat.isDirectory()) {
|
|
331
|
+
if (fs.existsSync(dest)) {
|
|
332
|
+
fs.rmSync(dest, { recursive: true });
|
|
333
|
+
}
|
|
334
|
+
fs.cpSync(localFile.sourcePath, dest, { recursive: true });
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
fs.cpSync(localFile.sourcePath, dest);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Generate a manifest file with metadata
|
|
341
|
+
const manifest = {
|
|
342
|
+
base_image: this._context.baseImage,
|
|
343
|
+
hash: this.hash,
|
|
344
|
+
instructions_count: this._context.instructions.length,
|
|
345
|
+
local_files_count: this._context.localFiles.length,
|
|
346
|
+
};
|
|
347
|
+
const manifestPath = path.join(buildDir, "manifest.json");
|
|
348
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
349
|
+
return buildDir;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Write the image to a deployable folder in a temporary directory.
|
|
353
|
+
*
|
|
354
|
+
* @returns Path to the generated folder
|
|
355
|
+
*/
|
|
356
|
+
writeTemp() {
|
|
357
|
+
ensureNodeEnvironment();
|
|
358
|
+
const tempDir = path.join(os.tmpdir(), `blaxel-image-${Date.now()}`);
|
|
359
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
360
|
+
return this.write(tempDir);
|
|
361
|
+
}
|
|
362
|
+
async _createZip(buildDir) {
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
const chunks = [];
|
|
365
|
+
const archive = archiver("zip", {
|
|
366
|
+
zlib: { level: 9 },
|
|
367
|
+
});
|
|
368
|
+
archive.on("data", (chunk) => {
|
|
369
|
+
chunks.push(chunk);
|
|
370
|
+
});
|
|
371
|
+
archive.on("end", () => {
|
|
372
|
+
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
|
373
|
+
const result = new Uint8Array(totalLength);
|
|
374
|
+
let offset = 0;
|
|
375
|
+
for (const chunk of chunks) {
|
|
376
|
+
result.set(chunk, offset);
|
|
377
|
+
offset += chunk.length;
|
|
378
|
+
}
|
|
379
|
+
resolve(result);
|
|
380
|
+
});
|
|
381
|
+
archive.on("error", reject);
|
|
382
|
+
// Add all files from the build directory
|
|
383
|
+
archive.directory(buildDir, false);
|
|
384
|
+
void archive.finalize();
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
_createSandboxPayload(name, memory = 4096) {
|
|
388
|
+
const labels = {
|
|
389
|
+
"x-blaxel-auto-generated": "true",
|
|
390
|
+
};
|
|
391
|
+
const metadata = {
|
|
392
|
+
name,
|
|
393
|
+
labels,
|
|
394
|
+
};
|
|
395
|
+
const runtime = {
|
|
396
|
+
memory,
|
|
397
|
+
};
|
|
398
|
+
const spec = {
|
|
399
|
+
runtime,
|
|
400
|
+
};
|
|
401
|
+
return {
|
|
402
|
+
metadata,
|
|
403
|
+
spec,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
async _createSandboxWithUpload(sandbox) {
|
|
407
|
+
const name = sandbox.metadata?.name || "";
|
|
408
|
+
const body = sandbox;
|
|
409
|
+
await settings.authenticate();
|
|
410
|
+
const headers = {
|
|
411
|
+
"Content-Type": "application/json",
|
|
412
|
+
...settings.headers,
|
|
413
|
+
};
|
|
414
|
+
// Try PUT first (update), fall back to POST (create)
|
|
415
|
+
let response = await fetch(`${settings.baseUrl}/sandboxes/${name}?upload=true`, {
|
|
416
|
+
method: "PUT",
|
|
417
|
+
headers,
|
|
418
|
+
body: JSON.stringify(body),
|
|
419
|
+
});
|
|
420
|
+
// If 404, try create
|
|
421
|
+
if (response.status === 404) {
|
|
422
|
+
response = await fetch(`${settings.baseUrl}/sandboxes?upload=true`, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers,
|
|
425
|
+
body: JSON.stringify(body),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const uploadUrl = response.headers.get("x-blaxel-upload-url");
|
|
429
|
+
return { response, uploadUrl };
|
|
430
|
+
}
|
|
431
|
+
async _uploadZip(uploadUrl, zipContent) {
|
|
432
|
+
const response = await fetch(uploadUrl, {
|
|
433
|
+
method: "PUT",
|
|
434
|
+
headers: {
|
|
435
|
+
"Content-Type": "application/zip",
|
|
436
|
+
},
|
|
437
|
+
body: zipContent,
|
|
438
|
+
});
|
|
439
|
+
if (response.status >= 400) {
|
|
440
|
+
const text = await response.text();
|
|
441
|
+
throw new Error(`Upload failed with status ${response.status}: ${text}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async _getSandboxStatus(name) {
|
|
445
|
+
await settings.authenticate();
|
|
446
|
+
const response = await fetch(`${settings.baseUrl}/sandboxes/${name}`, {
|
|
447
|
+
method: "GET",
|
|
448
|
+
headers: settings.headers,
|
|
449
|
+
});
|
|
450
|
+
if (response.status === 200) {
|
|
451
|
+
const data = (await response.json());
|
|
452
|
+
return data.status || null;
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
async _waitForDeployment(name, timeout = 900000, // 15 minutes in ms
|
|
457
|
+
pollInterval = 3000, onStatusChange) {
|
|
458
|
+
const startTime = Date.now();
|
|
459
|
+
let lastStatus = null;
|
|
460
|
+
const terminalStates = new Set(["DEPLOYED", "FAILED", "TERMINATED"]);
|
|
461
|
+
let buildStarted = false;
|
|
462
|
+
while (Date.now() - startTime < timeout) {
|
|
463
|
+
const status = await this._getSandboxStatus(name);
|
|
464
|
+
if (status && status !== lastStatus) {
|
|
465
|
+
lastStatus = status;
|
|
466
|
+
if (onStatusChange) {
|
|
467
|
+
onStatusChange(status);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Track if the build has started (status changed from DEPLOYED)
|
|
471
|
+
if (status && status !== "DEPLOYED") {
|
|
472
|
+
buildStarted = true;
|
|
473
|
+
}
|
|
474
|
+
// Only consider DEPLOYED as terminal if the build has started
|
|
475
|
+
// This handles re-builds where status starts as DEPLOYED
|
|
476
|
+
if (status && terminalStates.has(status)) {
|
|
477
|
+
if (status === "FAILED") {
|
|
478
|
+
throw new Error(`Deployment failed for sandbox '${name}'`);
|
|
479
|
+
}
|
|
480
|
+
if (status === "TERMINATED") {
|
|
481
|
+
throw new Error(`Sandbox '${name}' was terminated`);
|
|
482
|
+
}
|
|
483
|
+
if (status === "DEPLOYED" && buildStarted) {
|
|
484
|
+
return status;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
await sleep(pollInterval);
|
|
488
|
+
}
|
|
489
|
+
throw new Error(`Deployment timed out after ${timeout / 1000} seconds`);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Build and deploy the image as a sandbox.
|
|
493
|
+
*
|
|
494
|
+
* This method:
|
|
495
|
+
* 1. Prepares the image for sandbox deployment (adds sandbox-api)
|
|
496
|
+
* 2. Builds the image folder
|
|
497
|
+
* 3. Creates a zip of the folder
|
|
498
|
+
* 4. Creates/updates the sandbox resource
|
|
499
|
+
* 5. Uploads the zip to Blaxel
|
|
500
|
+
* 6. Waits for deployment to complete
|
|
501
|
+
*
|
|
502
|
+
* @param options - Build options
|
|
503
|
+
* @returns The deployed Sandbox object
|
|
504
|
+
*/
|
|
505
|
+
async build(options) {
|
|
506
|
+
const { name, memory = 4096, timeout = 900000, onStatusChange, sandboxVersion = "latest" } = options;
|
|
507
|
+
// Prepare image for sandbox deployment (add sandbox-api and entrypoint)
|
|
508
|
+
const preparedImage = this._prepareForSandbox(sandboxVersion);
|
|
509
|
+
// Write the image folder
|
|
510
|
+
const buildDir = preparedImage.writeTemp();
|
|
511
|
+
try {
|
|
512
|
+
// Create zip
|
|
513
|
+
const zipContent = await this._createZip(buildDir);
|
|
514
|
+
// Create sandbox payload
|
|
515
|
+
const sandboxPayload = this._createSandboxPayload(name, memory);
|
|
516
|
+
// Create/update sandbox and get upload URL
|
|
517
|
+
const { response, uploadUrl } = await this._createSandboxWithUpload(sandboxPayload);
|
|
518
|
+
if (response.status >= 400) {
|
|
519
|
+
const text = await response.text();
|
|
520
|
+
throw new Error(`Failed to create sandbox: ${response.status} - ${text}`);
|
|
521
|
+
}
|
|
522
|
+
if (!uploadUrl) {
|
|
523
|
+
throw new Error("No upload URL returned from API");
|
|
524
|
+
}
|
|
525
|
+
// Upload the zip
|
|
526
|
+
await this._uploadZip(uploadUrl, zipContent);
|
|
527
|
+
// Wait for deployment to complete
|
|
528
|
+
await this._waitForDeployment(name, timeout, 3000, onStatusChange);
|
|
529
|
+
// Get the final sandbox state
|
|
530
|
+
await settings.authenticate();
|
|
531
|
+
const finalResponse = await fetch(`${settings.baseUrl}/sandboxes/${name}`, {
|
|
532
|
+
method: "GET",
|
|
533
|
+
headers: settings.headers,
|
|
534
|
+
});
|
|
535
|
+
if (finalResponse.status === 200) {
|
|
536
|
+
const sandbox = (await finalResponse.json());
|
|
537
|
+
return sandbox;
|
|
538
|
+
}
|
|
539
|
+
throw new Error(`Failed to get sandbox '${name}' after deployment`);
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
// Cleanup temp directory
|
|
543
|
+
const parentDir = path.resolve(buildDir, "..");
|
|
544
|
+
try {
|
|
545
|
+
fs.rmSync(parentDir, { recursive: true, force: true });
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// Ignore cleanup errors
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Build and deploy the image as a sandbox (sync version).
|
|
554
|
+
*
|
|
555
|
+
* @deprecated Use build() instead - this is provided for API parity with Python SDK
|
|
556
|
+
*/
|
|
557
|
+
buildSync(options) {
|
|
558
|
+
return this.build(options);
|
|
559
|
+
}
|
|
560
|
+
}
|