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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ RUN apt update && export DEBIAN_FRONTEND=noninteractive \
RUN apt update && export DEBIAN_FRONTEND=noninteractive \
&& apt -y install --no-install-recommends openjdk-11-jdk protobuf-compiler libprotobuf-dev

# Mount-s3 (AWS S3 FUSE driver)
RUN /bin/bash -c 'apt update && export DEBIAN_FRONTEND=noninteractive \
&& apt -y install --no-install-recommends fuse libfuse2 \
&& MOUNT_S3_ARCH=$([ "$TARGETARCH" = "amd64" ] && echo "x86_64" || echo "arm64") \
&& wget https://s3.amazonaws.com/mountpoint-s3-release/1.20.0/${MOUNT_S3_ARCH}/mount-s3-1.20.0-${MOUNT_S3_ARCH}.deb \
&& apt install -y ./mount-s3-1.20.0-${MOUNT_S3_ARCH}.deb \
&& rm -f mount-s3-1.20.0-${MOUNT_S3_ARCH}.deb'
# Telepresence
RUN curl -fL https://app.getambassador.io/download/tel2oss/releases/download/v2.17.0/telepresence-linux-${TARGETARCH} -o /usr/local/bin/telepresence && \
chmod a+x /usr/local/bin/telepresence
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/sandbox/dto/sandbox.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export class SandboxVolume {
example: '/data',
})
mountPath: string

@ApiPropertyOptional({
description:
'Optional subpath within the volume to mount. When specified, only this S3 prefix will be accessible. When omitted, the entire volume is mounted.',
example: 'users/alice',
})
subpath?: string
}

@ApiSchema({ name: 'Sandbox' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export class RunnerAdapterLegacy implements RunnerAdapter {
volumes: sandbox.volumes?.map((volume) => ({
volumeId: volume.volumeId,
mountPath: volume.mountPath,
subpath: volume.subpath,
})),
networkBlockAll: sandbox.networkBlockAll,
networkAllowList: sandbox.networkAllowList,
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/sandbox/services/sandbox.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
import { RedisLockProvider } from '../common/redis-lock.provider'
import { customAlphabet as customNanoid, nanoid, urlAlphabet } from 'nanoid'
import { WithInstrumentation } from '../../common/decorators/otel.decorator'
import { validateMountPaths } from '../utils/volume-mount-path-validation.util'
import { validateMountPaths, validateSubpaths } from '../utils/volume-mount-path-validation.util'
import { SandboxRepository } from '../repositories/sandbox.repository'
import { PortPreviewUrlDto } from '../dto/port-preview-url.dto'

Expand Down Expand Up @@ -1375,7 +1375,13 @@ export class SandboxService {
try {
validateMountPaths(volumes)
} catch (error) {
throw new BadRequestError(error instanceof Error ? error.message : 'Invalid volume mount paths')
throw new BadRequestError(error instanceof Error ? error.message : 'Invalid volume mount configuration')
}

try {
validateSubpaths(volumes)
} catch (error) {
throw new BadRequestError(error instanceof Error ? error.message : 'Invalid volume subpath configuration')
}

return volumes
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/sandbox/utils/volume-mount-path-validation.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,48 @@ export function validateMountPaths(volumes: SandboxVolume[]): void {
throw new Error(errors.join(', '))
}
}

/**
* Validates subpaths for sandbox volumes to ensure they are safe S3 key prefixes
* @param volumes - Array of SandboxVolume objects to validate
* @throws Error with descriptive message if any subpath is invalid
*/
export function validateSubpaths(volumes: SandboxVolume[]): void {
const errors: string[] = []

for (const volume of volumes) {
const subpath = volume.subpath

// Empty/undefined subpath is valid (means mount entire volume)
if (!subpath) {
continue
}

if (typeof subpath !== 'string') {
errors.push(`Invalid subpath ${subpath} (must be a string)`)
continue
}

// S3 keys should not start with /
if (subpath.startsWith('/')) {
errors.push(`Invalid subpath "${subpath}" (S3 key prefixes cannot start with /)`)
continue
}

// Prevent path traversal
if (subpath.includes('..')) {
errors.push(`Invalid subpath "${subpath}" (cannot contain .. for security)`)
continue
}

// No consecutive slashes
if (subpath.includes('//')) {
errors.push(`Invalid subpath "${subpath}" (cannot contain consecutive slashes)`)
continue
}
}

if (errors.length > 0) {
throw new Error(errors.join(', '))
}
}
15 changes: 15 additions & 0 deletions apps/docs/src/content/docs/en/volumes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ params = CreateSandboxFromSnapshotParams(
volumes=[VolumeMount(volumeId=volume.id, mountPath=mount_dir_1)],
)
sandbox = daytona.create(params)

# Mount a specific subpath within the volume
# This is useful for isolating data or implementing multi-tenancy
params = CreateSandboxFromSnapshotParams(
language="python",
volumes=[VolumeMount(volumeId=volume.id, mountPath=mount_dir_1, subpath="users/alice")],
)
sandbox2 = daytona.create(params)
```

</TabItem>
Expand All @@ -79,6 +87,13 @@ const sandbox1 = await daytona.create({
language: 'typescript',
volumes: [{ volumeId: volume.id, mountPath: mountDir1 }],
})

// Mount a specific subpath within the volume
// This is useful for isolating data or implementing multi-tenancy
const sandbox2 = await daytona.create({
language: 'typescript',
volumes: [{ volumeId: volume.id, mountPath: mountDir, subpath: 'users/alice' }],
})
```

</TabItem>
Expand Down
18 changes: 18 additions & 0 deletions apps/docs/src/content/docs/ja/volumes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ sandbox = daytona.create(params)
# サンドボックスを削除しても、ボリュームは保持されます
sandbox.delete()

# ボリューム内の特定のサブパスをマウントします
# これは、データの分離やマルチテナントの実装に役立ちます
params = CreateSandboxFromSnapshotParams(
language="python",
volumes=[VolumeMount(volumeId=volume.id, mountPath=mount_dir_1, subpath="users/alice")],
)
sandbox2 = daytona.create(params)

sandbox2.delete()
```

</TabItem>
Expand All @@ -80,6 +89,15 @@ async function main() {
// サンドボックスの利用が終わったら、削除できます
// サンドボックスを削除しても、ボリュームは保持されます
await sandbox1.delete()

// ボリューム内の特定のサブパスをマウントします
// これは、データの分離やマルチテナントの実装に役立ちます
const sandbox2 = await daytona.create({
language: 'typescript',
volumes: [{ volumeId: volume.id, mountPath: mountDir1, subpath: 'users/alice' }],
})

await sandbox2.delete()
}

main()
Expand Down
3 changes: 3 additions & 0 deletions apps/runner/pkg/api/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions apps/runner/pkg/api/docs/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/runner/pkg/api/docs/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions apps/runner/pkg/api/dto/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package dto

type VolumeDTO struct {
VolumeId string `json:"volumeId"`
MountPath string `json:"mountPath"`
VolumeId string `json:"volumeId"`
MountPath string `json:"mountPath"`
Subpath *string `json:"subpath,omitempty"`
}
68 changes: 54 additions & 14 deletions apps/runner/pkg/docker/volumes_mountpaths.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ package docker

import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"

"github.com/daytonaio/runner/cmd/runner/config"
Expand All @@ -23,23 +26,31 @@ func (d *DockerClient) getVolumesMountPathBinds(ctx context.Context, volumes []d

for _, vol := range volumes {
volumeIdPrefixed := fmt.Sprintf("daytona-volume-%s", vol.VolumeId)
runnerVolumeMountPath := d.getRunnerVolumeMountPath(volumeIdPrefixed)
runnerVolumeMountPath := d.getRunnerVolumeMountPath(volumeIdPrefixed, vol.Subpath)

// Get or create mutex for this volume
// Create unique key for this volume+subpath combination for mutex
volumeKey := d.getVolumeKey(volumeIdPrefixed, vol.Subpath)

// Get or create mutex for this volume+subpath
d.volumeMutexesMutex.Lock()
volumeMutex, exists := d.volumeMutexes[volumeIdPrefixed]
volumeMutex, exists := d.volumeMutexes[volumeKey]
if !exists {
volumeMutex = &sync.Mutex{}
d.volumeMutexes[volumeIdPrefixed] = volumeMutex
d.volumeMutexes[volumeKey] = volumeMutex
}
d.volumeMutexesMutex.Unlock()

// Lock this specific volume's mutex
volumeMutex.Lock()
defer volumeMutex.Unlock()

subpathStr := ""
if vol.Subpath != nil {
subpathStr = *vol.Subpath
}

if d.isDirectoryMounted(runnerVolumeMountPath) {
log.Infof("volume %s is already mounted to %s", volumeIdPrefixed, runnerVolumeMountPath)
log.Infof("volume %s (subpath: %s) is already mounted to %s", volumeIdPrefixed, subpathStr, runnerVolumeMountPath)
volumeMountPathBinds = append(volumeMountPathBinds, fmt.Sprintf("%s/:%s/", runnerVolumeMountPath, vol.MountPath))
continue
}
Expand All @@ -49,40 +60,69 @@ func (d *DockerClient) getVolumesMountPathBinds(ctx context.Context, volumes []d
return nil, fmt.Errorf("failed to create mount directory %s: %s", runnerVolumeMountPath, err)
}

log.Infof("mounting S3 volume %s to %s", volumeIdPrefixed, runnerVolumeMountPath)
log.Infof("mounting S3 volume %s (subpath: %s) to %s", volumeIdPrefixed, subpathStr, runnerVolumeMountPath)

cmd := d.getMountCmd(ctx, volumeIdPrefixed, runnerVolumeMountPath)
cmd := d.getMountCmd(ctx, volumeIdPrefixed, vol.Subpath, runnerVolumeMountPath)
err = cmd.Run()
if err != nil {
return nil, fmt.Errorf("failed to mount S3 volume %s to %s: %s", volumeIdPrefixed, runnerVolumeMountPath, err)
return nil, fmt.Errorf("failed to mount S3 volume %s (subpath: %s) to %s: %s", volumeIdPrefixed, subpathStr, runnerVolumeMountPath, err)
}

log.Infof("mounted S3 volume %s to %s", volumeIdPrefixed, runnerVolumeMountPath)
log.Infof("mounted S3 volume %s (subpath: %s) to %s", volumeIdPrefixed, subpathStr, runnerVolumeMountPath)

volumeMountPathBinds = append(volumeMountPathBinds, fmt.Sprintf("%s/:%s/", runnerVolumeMountPath, vol.MountPath))
}

return volumeMountPathBinds, nil
}

func (d *DockerClient) getRunnerVolumeMountPath(volumeId string) string {
volumePath := filepath.Join("/mnt", volumeId)
func (d *DockerClient) getRunnerVolumeMountPath(volumeId string, subpath *string) string {
// If subpath is provided, create a unique mount point for this volume+subpath combination
mountDirName := volumeId
if subpath != nil && *subpath != "" {
// Create a short hash of the subpath to keep the path reasonable
hash := md5.Sum([]byte(*subpath))
hashStr := hex.EncodeToString(hash[:])[:8]
mountDirName = fmt.Sprintf("%s-%s", volumeId, hashStr)
}

volumePath := filepath.Join("/mnt", mountDirName)
if config.GetEnvironment() == "development" {
volumePath = filepath.Join("/tmp", volumeId)
volumePath = filepath.Join("/tmp", mountDirName)
}

return volumePath
}

func (d *DockerClient) getVolumeKey(volumeId string, subpath *string) string {
if subpath == nil || *subpath == "" {
return volumeId
}
return fmt.Sprintf("%s:%s", volumeId, *subpath)
}

func (d *DockerClient) isDirectoryMounted(path string) bool {
cmd := exec.Command("mountpoint", path)
_, err := cmd.Output()

return err == nil
}

func (d *DockerClient) getMountCmd(ctx context.Context, volume, path string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "mount-s3", "--allow-other", "--allow-delete", "--allow-overwrite", "--file-mode", "0666", "--dir-mode", "0777", volume, path)
func (d *DockerClient) getMountCmd(ctx context.Context, volume string, subpath *string, path string) *exec.Cmd {
args := []string{"--allow-other", "--allow-delete", "--allow-overwrite", "--file-mode", "0666", "--dir-mode", "0777"}

if subpath != nil && *subpath != "" {
// Ensure subpath ends with /
prefix := *subpath
if !strings.HasSuffix(prefix, "/") {
prefix = prefix + "/"
}
args = append(args, "--prefix", prefix)
}

args = append(args, volume, path)

cmd := exec.CommandContext(ctx, "mount-s3", args...)

if d.awsEndpointUrl != "" {
cmd.Env = append(cmd.Env, "AWS_ENDPOINT_URL="+d.awsEndpointUrl)
Expand Down
19 changes: 19 additions & 0 deletions examples/python/volumes/_async/volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,28 @@ async def main():
file = await sandbox2.fs.download_file(os.path.join(mount_dir_2, "new-file.txt"))
print("File:", file)

# Mount a specific subpath within the volume
# This is useful for isolating data or implementing multi-tenancy
mount_dir_3 = "/home/daytona/subpath"

params = CreateSandboxFromSnapshotParams(
language="python",
volumes=[VolumeMount(volumeId=volume.id, mountPath=mount_dir_3, subpath="users/alice")],
)
sandbox3 = await daytona.create(params)

# This sandbox will only see files within the 'users/alice' subpath
# Create a file in the subpath
subpath_file = os.path.join(mount_dir_3, "alice-file.txt")
await sandbox3.fs.upload_file(b"Hello from Alice's subpath!", subpath_file)

# The file is stored at: volume-root/users/alice/alice-file.txt
# but appears at: /home/daytona/subpath/alice-file.txt in the sandbox

# Cleanup
await daytona.delete(sandbox)
await daytona.delete(sandbox2)
await daytona.delete(sandbox3)
# daytona.volume.delete(volume)


Expand Down
19 changes: 19 additions & 0 deletions examples/python/volumes/volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,28 @@ def main():
file = sandbox2.fs.download_file(os.path.join(mount_dir_2, "new-file.txt"))
print("File:", file)

# Mount a specific subpath within the volume
# This is useful for isolating data or implementing multi-tenancy
mount_dir_3 = "/home/daytona/subpath"

params = CreateSandboxFromSnapshotParams(
language="python",
volumes=[VolumeMount(volumeId=volume.id, mountPath=mount_dir_3, subpath="users/alice")],
)
sandbox3 = daytona.create(params)

# This sandbox will only see files within the 'users/alice' subpath
# Create a file in the subpath
subpath_file = os.path.join(mount_dir_3, "alice-file.txt")
sandbox3.fs.upload_file(b"Hello from Alice's subpath!", subpath_file)

# The file is stored at: volume-root/users/alice/alice-file.txt
# but appears at: /home/daytona/subpath/alice-file.txt in the sandbox

# Cleanup
daytona.delete(sandbox)
daytona.delete(sandbox2)
daytona.delete(sandbox3)
# daytona.volume.delete(volume)


Expand Down
Loading
Loading