@mastra/daytona 0.10.0 → 0.10.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/index.cjs +118 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +119 -39
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/mounts/s3-credentials.d.ts +5 -0
- package/dist/sandbox/mounts/s3-credentials.d.ts.map +1 -0
- package/dist/sandbox/mounts/s3.d.ts +2 -0
- package/dist/sandbox/mounts/s3.d.ts.map +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -37,6 +37,23 @@ await workspace.destroy();
|
|
|
37
37
|
- [Daytona integration guide](https://mastra.ai/integrations/sandboxes/daytona)
|
|
38
38
|
- [Workspace documentation](https://mastra.ai/docs/mastra-platform/workspaces)
|
|
39
39
|
|
|
40
|
+
### R2 integration test
|
|
41
|
+
|
|
42
|
+
The opt-in S3 mount test creates billable Daytona sandboxes and disposable R2 objects. Use a dedicated test bucket with test-only credentials that can list, read, write, and delete its objects. The lifecycle checks also mount with those long-lived credentials; don't supply production credentials.
|
|
43
|
+
|
|
44
|
+
Prepare a Daytona snapshot containing Python and `boto3` before running the test. Pin Python dependencies when building the snapshot, and set `DAYTONA_R2_TEST_SNAPSHOT` to its name. You can omit this variable if your default snapshot already contains these dependencies. The test fails with setup instructions if they're missing; it doesn't install Python packages at runtime. Preinstalling `s3fs` also avoids the mount provider's existing runtime system-package installation.
|
|
45
|
+
|
|
46
|
+
Set `DAYTONA_API_KEY`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY` in the repository-root `.env`, then run from the repository root:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
DOTENV_CONFIG_PATH="$PWD/.env" \
|
|
50
|
+
RUN_R2_ISOLATION_TEST=1 \
|
|
51
|
+
DAYTONA_R2_TEST_SNAPSHOT=your-prepared-snapshot \
|
|
52
|
+
pnpm --filter @mastra/daytona exec vitest run src/sandbox/mounts/s3-r2.integration.test.ts
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The test checks cross-prefix access denial, mount recovery, credential cleanup, sandbox deletion, and removal of its test objects.
|
|
56
|
+
|
|
40
57
|
## Changelog
|
|
41
58
|
|
|
42
59
|
See the [package changelog](https://github.com/mastra-ai/mastra/blob/main/workspaces/daytona/CHANGELOG.md) for version history and release notes.
|
package/dist/index.cjs
CHANGED
|
@@ -77,6 +77,51 @@ async function runCommand(sandbox, command, options) {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
//#endregion
|
|
80
|
+
//#region src/sandbox/mounts/s3-credentials.ts
|
|
81
|
+
function s3CredentialsPrefix(mountPath) {
|
|
82
|
+
return `/tmp/.mastra-s3-${(0, crypto.createHash)("sha256").update(mountPath.replace(/\/$/, "")).digest("hex")}-`;
|
|
83
|
+
}
|
|
84
|
+
/** Remove credentials only after their s3fs process has exited, including after reconnects. */
|
|
85
|
+
async function cleanupS3Credentials(mountPath, ctx) {
|
|
86
|
+
const script = `test -r /proc/self/comm || exit 1
|
|
87
|
+
credentials_in_use() {
|
|
88
|
+
for process in /proc/[0-9]*; do
|
|
89
|
+
test -d "$process" || continue
|
|
90
|
+
name=$(cat "$process/comm" 2>/dev/null) || {
|
|
91
|
+
test -d "$process" && return 0
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
test "$name" = s3fs || continue
|
|
95
|
+
args=$(tr '\\000' '\\n' < "$process/cmdline") || {
|
|
96
|
+
test -d "$process" && return 0
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
printf '%s\\n' "$args" | grep -Fxq -- "passwd_file=$directory/credentials" && return 0
|
|
100
|
+
done
|
|
101
|
+
return 1
|
|
102
|
+
}
|
|
103
|
+
result=0
|
|
104
|
+
for directory in ${shellQuote(s3CredentialsPrefix(mountPath))}*; do
|
|
105
|
+
test -d "$directory" && test ! -L "$directory" || continue
|
|
106
|
+
attempt=0
|
|
107
|
+
while credentials_in_use && test "$attempt" -lt 20; do
|
|
108
|
+
sleep 0.25
|
|
109
|
+
attempt=$((attempt + 1))
|
|
110
|
+
done
|
|
111
|
+
if credentials_in_use; then
|
|
112
|
+
result=1
|
|
113
|
+
continue
|
|
114
|
+
fi
|
|
115
|
+
rm -f -- "$directory/credentials" && rmdir -- "$directory" || result=1
|
|
116
|
+
done
|
|
117
|
+
exit "$result"`;
|
|
118
|
+
try {
|
|
119
|
+
if ((await ctx.run(`sh -c ${shellQuote(script)}`, 3e4)).exitCode !== 0) ctx.logger.warn(`${LOG_PREFIX} S3 credentials retained: daemon still active or cleanup could not be verified`);
|
|
120
|
+
} catch {
|
|
121
|
+
ctx.logger.warn(`${LOG_PREFIX} Could not clean up S3 credentials`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
80
125
|
//#region src/sandbox/mounts/s3.ts
|
|
81
126
|
/**
|
|
82
127
|
* Mount an S3 bucket using s3fs-fuse.
|
|
@@ -90,6 +135,7 @@ async function mountS3(mountPath, config, ctx) {
|
|
|
90
135
|
const hasSecretKey = !!config.secretAccessKey;
|
|
91
136
|
if (hasAccessKey !== hasSecretKey) throw new Error("Both accessKeyId and secretAccessKey must be provided together.");
|
|
92
137
|
const hasCredentials = hasAccessKey && hasSecretKey;
|
|
138
|
+
if (config.sessionToken && !hasCredentials) throw new Error("sessionToken requires accessKeyId and secretAccessKey.");
|
|
93
139
|
if (!hasCredentials && config.endpoint) throw new Error(`S3-compatible storage requires credentials. Detected endpoint: ${config.endpoint}. The public_bucket option only works for AWS S3 public buckets, not R2, MinIO, etc.`);
|
|
94
140
|
if (config.endpoint) {
|
|
95
141
|
const endpoint = config.endpoint.replace(/\/$/, "");
|
|
@@ -109,43 +155,62 @@ async function mountS3(mountPath, config, ctx) {
|
|
|
109
155
|
const [uid, gid] = idResult.stdout.trim().split("\n");
|
|
110
156
|
const validUidGid = uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid);
|
|
111
157
|
if (!validUidGid) logger.warn(`${LOG_PREFIX} Unexpected uid/gid format: "${idResult.stdout.trim()}" — mounted files will be owned by root`);
|
|
112
|
-
const
|
|
158
|
+
const credentialsDirectory = `${s3CredentialsPrefix(mountPath)}${(0, crypto.randomUUID)()}`;
|
|
159
|
+
const credentialsPath = `${credentialsDirectory}/credentials`;
|
|
113
160
|
await run("sudo chmod a+rw /dev/fuse 2>/dev/null || true; sudo bash -c 'grep -q \"^user_allow_other\" /etc/fuse.conf 2>/dev/null || echo \"user_allow_other\" >> /etc/fuse.conf' 2>/dev/null || true");
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
161
|
+
let credentialsCreated = false;
|
|
162
|
+
let mountAttempted = false;
|
|
163
|
+
try {
|
|
164
|
+
if (config.accessKeyId && config.secretAccessKey) {
|
|
165
|
+
if ((await run(`mkdir -m 700 ${shellQuote(credentialsDirectory)}`, 3e4)).exitCode !== 0) throw new Error("Failed to create private S3 credentials directory");
|
|
166
|
+
credentialsCreated = true;
|
|
167
|
+
await writeFile(credentialsPath, config.sessionToken ? [
|
|
168
|
+
`export AWS_ACCESS_KEY_ID=${shellQuote(config.accessKeyId)}`,
|
|
169
|
+
`export AWS_SECRET_ACCESS_KEY=${shellQuote(config.secretAccessKey)}`,
|
|
170
|
+
`export AWS_SESSION_TOKEN=${shellQuote(config.sessionToken)}`
|
|
171
|
+
].join("\n") : `${config.accessKeyId}:${config.secretAccessKey}`);
|
|
172
|
+
if ((await run(`chmod 600 ${shellQuote(credentialsPath)}`, 3e4)).exitCode !== 0) throw new Error("Failed to restrict S3 credentials file permissions");
|
|
173
|
+
}
|
|
174
|
+
const mountOptions = [];
|
|
175
|
+
if (hasCredentials) mountOptions.push(config.sessionToken ? "use_session_token" : `passwd_file=${credentialsPath}`);
|
|
176
|
+
else {
|
|
177
|
+
mountOptions.push("public_bucket=1");
|
|
178
|
+
logger.debug(`${LOG_PREFIX} No credentials provided, mounting as public bucket (read-only)`);
|
|
179
|
+
}
|
|
180
|
+
mountOptions.push("allow_other");
|
|
181
|
+
if (validUidGid) mountOptions.push(`uid=${uid}`, `gid=${gid}`);
|
|
182
|
+
if (config.endpoint) {
|
|
183
|
+
const endpoint = config.endpoint.replace(/\/$/, "");
|
|
184
|
+
mountOptions.push(`url=${shellQuote(endpoint)}`, "use_path_request_style", "sigv4", "nomultipart");
|
|
185
|
+
}
|
|
186
|
+
if (config.readOnly) {
|
|
187
|
+
mountOptions.push("ro");
|
|
188
|
+
logger.debug(`${LOG_PREFIX} Mounting as read-only`);
|
|
189
|
+
}
|
|
190
|
+
let bucketArg = config.bucket;
|
|
191
|
+
if (config.prefix) {
|
|
192
|
+
const normalizedPrefix = validatePrefix(config.prefix);
|
|
193
|
+
bucketArg = `${config.bucket}:/${normalizedPrefix}`;
|
|
194
|
+
}
|
|
195
|
+
const mountCmd = `${config.sessionToken ? `. ${shellQuote(credentialsPath)} && ` : ""}s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
|
|
196
|
+
logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
|
|
197
|
+
mountAttempted = true;
|
|
198
|
+
const result = await run(mountCmd, 6e4);
|
|
199
|
+
logger.debug(`${LOG_PREFIX} s3fs result:`, {
|
|
200
|
+
exitCode: result.exitCode,
|
|
201
|
+
stdout: result.stdout,
|
|
202
|
+
stderr: result.stderr
|
|
203
|
+
});
|
|
204
|
+
if (result.exitCode !== 0) throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
|
|
205
|
+
const probe = await run(`timeout -k 5s 15s stat -L -- ${quotedMountPath} > /dev/null`, 3e4);
|
|
206
|
+
if (probe.exitCode !== 0) throw new Error(`S3 mount is not readable (exit ${probe.exitCode}): ${probe.stderr || probe.stdout}`);
|
|
207
|
+
} finally {
|
|
208
|
+
if (credentialsCreated && (config.sessionToken || !mountAttempted)) try {
|
|
209
|
+
if ((await run(`rm -f ${shellQuote(credentialsPath)} && rmdir ${shellQuote(credentialsDirectory)}`, 3e4)).exitCode !== 0) logger.warn(`${LOG_PREFIX} Failed to remove S3 credentials`);
|
|
210
|
+
} catch {
|
|
211
|
+
logger.warn(`${LOG_PREFIX} Failed to remove S3 credentials`);
|
|
212
|
+
}
|
|
139
213
|
}
|
|
140
|
-
const mountCmd = `s3fs ${shellQuote(bucketArg)} ${quotedMountPath} -o ${mountOptions.join(" -o ")}`;
|
|
141
|
-
logger.debug(`${LOG_PREFIX} Mounting S3:`, hasCredentials ? mountCmd.replace(credentialsPath, "***") : mountCmd);
|
|
142
|
-
const result = await run(mountCmd, 6e4);
|
|
143
|
-
logger.debug(`${LOG_PREFIX} s3fs result:`, {
|
|
144
|
-
exitCode: result.exitCode,
|
|
145
|
-
stdout: result.stdout,
|
|
146
|
-
stderr: result.stderr
|
|
147
|
-
});
|
|
148
|
-
if (result.exitCode !== 0) throw new Error(`Failed to mount S3 bucket: ${result.stderr || result.stdout}`);
|
|
149
214
|
}
|
|
150
215
|
//#endregion
|
|
151
216
|
//#region src/sandbox/mounts/gcs.ts
|
|
@@ -1221,14 +1286,18 @@ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraS
|
|
|
1221
1286
|
}
|
|
1222
1287
|
} catch (error) {
|
|
1223
1288
|
this.logger.error(`${LOG_PREFIX} Error mounting "${filesystem.provider}" (${filesystem.id}) at "${mountPath}":`, error);
|
|
1289
|
+
if (config.type === "s3") try {
|
|
1290
|
+
await this.unmount(mountPath);
|
|
1291
|
+
} catch (cleanupError) {
|
|
1292
|
+
this.logger.warn(`${LOG_PREFIX} Could not unmount failed S3 mount at ${mountPath}:`, cleanupError);
|
|
1293
|
+
}
|
|
1224
1294
|
this.mounts.set(mountPath, {
|
|
1225
1295
|
filesystem,
|
|
1226
1296
|
state: "error",
|
|
1227
1297
|
config,
|
|
1228
1298
|
error: errorToString(error)
|
|
1229
1299
|
});
|
|
1230
|
-
await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
|
|
1231
|
-
this.logger.debug(`${LOG_PREFIX} Cleaned up directory after failed mount: ${mountPath}`);
|
|
1300
|
+
if (config.type !== "s3") await runCommand(sandbox, `sudo rmdir ${shellQuote(mountPath)} 2>/dev/null || true`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
|
|
1232
1301
|
return {
|
|
1233
1302
|
success: false,
|
|
1234
1303
|
mountPath,
|
|
@@ -1255,7 +1324,18 @@ var DaytonaSandbox = class DaytonaSandbox extends _mastra_core_workspace.MastraS
|
|
|
1255
1324
|
const sandbox = this._sandbox;
|
|
1256
1325
|
this.logger.debug(`${LOG_PREFIX} Unmounting "${mountPath}"...`);
|
|
1257
1326
|
const quotedPath = shellQuote(mountPath);
|
|
1258
|
-
await runCommand(sandbox, `sudo fusermount -u ${quotedPath} 2>/dev/null; sudo umount -l ${quotedPath} 2>/dev/null;
|
|
1327
|
+
await runCommand(sandbox, `sudo fusermount -u ${quotedPath} 2>/dev/null; sudo umount -l ${quotedPath} 2>/dev/null; grep -Fq -- ${shellQuote(` ${(mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath) || "/"} `)} /proc/mounts && { _p="/tmp/.mastra-defunct-$$"; sudo mkdir -p "$_p" && sudo mount --move ${quotedPath} "$_p" 2>/dev/null; sudo umount -l "$_p" 2>/dev/null; sudo rmdir "$_p" 2>/dev/null; }`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
|
|
1328
|
+
await cleanupS3Credentials(mountPath, {
|
|
1329
|
+
run: async (cmd, timeout) => {
|
|
1330
|
+
const result = await runCommand(sandbox, cmd, { timeout: timeout ?? MOUNT_COMMAND_TIMEOUT_MS });
|
|
1331
|
+
return {
|
|
1332
|
+
exitCode: result.exitCode,
|
|
1333
|
+
stdout: result.output,
|
|
1334
|
+
stderr: ""
|
|
1335
|
+
};
|
|
1336
|
+
},
|
|
1337
|
+
logger: this.logger
|
|
1338
|
+
});
|
|
1259
1339
|
this.mounts.delete(mountPath);
|
|
1260
1340
|
const rmdirResult = await runCommand(sandbox, `rm -f ${shellQuote(`/tmp/.mastra-mounts/${this.mounts.markerFilename(mountPath)}`)} 2>/dev/null; sudo rmdir ${quotedPath} 2>&1`, { timeout: MOUNT_COMMAND_TIMEOUT_MS });
|
|
1261
1341
|
if (rmdirResult.exitCode === 0) this.logger.debug(`${LOG_PREFIX} Unmounted and removed ${mountPath}`);
|