moru 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- moru/__init__.py +8 -0
- moru/api/__init__.py +4 -0
- moru/api/client/__init__.py +1 -1
- moru/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py +4 -0
- moru/api/client/api/sandboxes/get_sandboxes.py +4 -0
- moru/api/client/api/sandboxes/get_sandboxes_metrics.py +5 -1
- moru/api/client/api/sandboxes/get_sandboxes_sandbox_id.py +4 -0
- moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py +67 -23
- moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py +5 -0
- moru/api/client/api/sandboxes/get_v2_sandbox_runs.py +218 -0
- moru/api/client/api/sandboxes/get_v2_sandboxes.py +5 -2
- moru/api/client/api/sandboxes/post_sandboxes.py +4 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py +6 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py +5 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py +3 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py +5 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py +4 -0
- moru/api/client/api/templates/delete_templates_template_id.py +3 -0
- moru/api/client/api/templates/get_templates.py +3 -0
- moru/api/client/api/templates/get_templates_template_id.py +3 -0
- moru/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py +276 -0
- moru/api/client/api/templates/get_templates_template_id_builds_build_id_status.py +23 -4
- moru/api/client/api/templates/get_templates_template_id_files_hash.py +5 -0
- moru/api/client/api/templates/patch_templates_template_id.py +4 -0
- moru/api/client/api/templates/post_templates.py +4 -0
- moru/api/client/api/templates/post_templates_template_id.py +3 -0
- moru/api/client/api/templates/post_templates_template_id_builds_build_id.py +3 -0
- moru/api/client/api/templates/post_v2_templates.py +4 -0
- moru/api/client/api/templates/post_v3_templates.py +4 -0
- moru/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py +3 -0
- moru/api/client/models/__init__.py +30 -0
- moru/api/client/models/admin_sandbox_kill_result.py +67 -0
- moru/api/client/models/build_log_entry.py +1 -1
- moru/api/client/models/create_volume_request.py +59 -0
- moru/api/client/models/file_info.py +105 -0
- moru/api/client/models/file_info_type.py +9 -0
- moru/api/client/models/file_list_response.py +84 -0
- moru/api/client/models/logs_direction.py +9 -0
- moru/api/client/models/logs_source.py +9 -0
- moru/api/client/models/machine_info.py +83 -0
- moru/api/client/models/new_sandbox.py +19 -0
- moru/api/client/models/node.py +10 -0
- moru/api/client/models/node_detail.py +10 -0
- moru/api/client/models/sandbox_log_entry.py +9 -9
- moru/api/client/models/sandbox_log_event_type.py +11 -0
- moru/api/client/models/sandbox_run.py +130 -0
- moru/api/client/models/sandbox_run_end_reason.py +11 -0
- moru/api/client/models/sandbox_run_status.py +10 -0
- moru/api/client/models/template_build_logs_response.py +73 -0
- moru/api/client/models/upload_response.py +67 -0
- moru/api/client/models/volume.py +105 -0
- moru/sandbox/mcp.py +835 -6
- moru/sandbox_async/commands/command.py +5 -1
- moru/sandbox_async/filesystem/filesystem.py +5 -1
- moru/sandbox_async/main.py +21 -0
- moru/sandbox_async/sandbox_api.py +17 -11
- moru/sandbox_sync/filesystem/filesystem.py +5 -1
- moru/sandbox_sync/main.py +21 -0
- moru/sandbox_sync/sandbox_api.py +17 -11
- moru/volume/__init__.py +11 -0
- moru/volume/types.py +83 -0
- moru/volume/volume_api.py +330 -0
- moru/volume_async/__init__.py +5 -0
- moru/volume_async/main.py +327 -0
- moru/volume_async/volume_api.py +290 -0
- moru/volume_sync/__init__.py +5 -0
- moru/volume_sync/main.py +325 -0
- moru-0.2.0.dist-info/METADATA +122 -0
- {moru-0.1.0.dist-info → moru-0.2.0.dist-info}/RECORD +71 -46
- {moru-0.1.0.dist-info → moru-0.2.0.dist-info}/WHEEL +1 -1
- moru-0.1.0.dist-info/METADATA +0 -63
- {moru-0.1.0.dist-info/licenses → moru-0.2.0.dist-info}/LICENSE +0 -0
|
@@ -15,7 +15,11 @@ from moru.envd.versions import ENVD_COMMANDS_STDIN
|
|
|
15
15
|
from moru.exceptions import SandboxException
|
|
16
16
|
from moru.sandbox.commands.main import ProcessInfo
|
|
17
17
|
from moru.sandbox.commands.command_handle import CommandResult
|
|
18
|
-
from moru.sandbox_async.commands.command_handle import
|
|
18
|
+
from moru.sandbox_async.commands.command_handle import (
|
|
19
|
+
AsyncCommandHandle,
|
|
20
|
+
Stderr,
|
|
21
|
+
Stdout,
|
|
22
|
+
)
|
|
19
23
|
from moru.sandbox_async.utils import OutputHandler
|
|
20
24
|
|
|
21
25
|
|
|
@@ -16,7 +16,11 @@ from moru.envd.api import ENVD_API_FILES_ROUTE, ahandle_envd_api_exception
|
|
|
16
16
|
from moru.envd.filesystem import filesystem_connect, filesystem_pb2
|
|
17
17
|
from moru.envd.rpc import authentication_header, handle_rpc_exception
|
|
18
18
|
from moru.envd.versions import ENVD_VERSION_RECURSIVE_WATCH, ENVD_DEFAULT_USER
|
|
19
|
-
from moru.exceptions import
|
|
19
|
+
from moru.exceptions import (
|
|
20
|
+
SandboxException,
|
|
21
|
+
TemplateException,
|
|
22
|
+
InvalidArgumentException,
|
|
23
|
+
)
|
|
20
24
|
from moru.sandbox.filesystem.filesystem import (
|
|
21
25
|
WriteInfo,
|
|
22
26
|
EntryInfo,
|
moru/sandbox_async/main.py
CHANGED
|
@@ -16,6 +16,7 @@ from moru.exceptions import SandboxException, format_request_timeout_error
|
|
|
16
16
|
from moru.sandbox.main import SandboxOpts
|
|
17
17
|
from moru.sandbox.sandbox_api import McpServer, SandboxMetrics, SandboxNetworkOpts
|
|
18
18
|
from moru.sandbox.utils import class_method_variant
|
|
19
|
+
from moru.volume.volume_api import validate_mount_path
|
|
19
20
|
from moru.sandbox_async.commands.command import Commands
|
|
20
21
|
from moru.sandbox_async.commands.pty import Pty
|
|
21
22
|
from moru.sandbox_async.filesystem.filesystem import Filesystem
|
|
@@ -153,6 +154,8 @@ class AsyncSandbox(SandboxApi):
|
|
|
153
154
|
allow_internet_access: bool = True,
|
|
154
155
|
mcp: Optional[McpServer] = None,
|
|
155
156
|
network: Optional[SandboxNetworkOpts] = None,
|
|
157
|
+
volume_id: Optional[str] = None,
|
|
158
|
+
volume_mount_path: Optional[str] = None,
|
|
156
159
|
**opts: Unpack[ApiParams],
|
|
157
160
|
) -> Self:
|
|
158
161
|
"""
|
|
@@ -168,11 +171,23 @@ class AsyncSandbox(SandboxApi):
|
|
|
168
171
|
:param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`.
|
|
169
172
|
:param mcp: MCP server to enable in the sandbox
|
|
170
173
|
:param network: Sandbox network configuration
|
|
174
|
+
:param volume_id: Volume ID to attach (e.g., vol_abc123). Requires volume_mount_path.
|
|
175
|
+
:param volume_mount_path: Mount path inside sandbox. Required if volume_id is provided.
|
|
176
|
+
Must start with /workspace/, /data/, /mnt/, or /volumes/.
|
|
177
|
+
Note: Mounting overlays any existing files/directories at the mount path.
|
|
171
178
|
|
|
172
179
|
:return: A Sandbox instance for the new sandbox
|
|
173
180
|
|
|
174
181
|
Use this method instead of using the constructor to create a new sandbox.
|
|
175
182
|
"""
|
|
183
|
+
# Validate volume parameters
|
|
184
|
+
if volume_id and not volume_mount_path:
|
|
185
|
+
raise ValueError("volume_mount_path is required when volume_id is provided")
|
|
186
|
+
if volume_mount_path and not volume_id:
|
|
187
|
+
raise ValueError("volume_id is required when volume_mount_path is provided")
|
|
188
|
+
if volume_mount_path:
|
|
189
|
+
validate_mount_path(volume_mount_path)
|
|
190
|
+
|
|
176
191
|
if not template and mcp is not None:
|
|
177
192
|
template = cls.default_mcp_template
|
|
178
193
|
elif not template:
|
|
@@ -188,6 +203,8 @@ class AsyncSandbox(SandboxApi):
|
|
|
188
203
|
allow_internet_access=allow_internet_access,
|
|
189
204
|
mcp=mcp,
|
|
190
205
|
network=network,
|
|
206
|
+
volume_id=volume_id,
|
|
207
|
+
volume_mount_path=volume_mount_path,
|
|
191
208
|
**opts,
|
|
192
209
|
)
|
|
193
210
|
|
|
@@ -680,6 +697,8 @@ class AsyncSandbox(SandboxApi):
|
|
|
680
697
|
secure: bool,
|
|
681
698
|
mcp: Optional[McpServer] = None,
|
|
682
699
|
network: Optional[SandboxNetworkOpts] = None,
|
|
700
|
+
volume_id: Optional[str] = None,
|
|
701
|
+
volume_mount_path: Optional[str] = None,
|
|
683
702
|
**opts: Unpack[ApiParams],
|
|
684
703
|
) -> Self:
|
|
685
704
|
extra_sandbox_headers = {}
|
|
@@ -702,6 +721,8 @@ class AsyncSandbox(SandboxApi):
|
|
|
702
721
|
allow_internet_access=allow_internet_access,
|
|
703
722
|
mcp=mcp,
|
|
704
723
|
network=network,
|
|
724
|
+
volume_id=volume_id,
|
|
725
|
+
volume_mount_path=volume_mount_path,
|
|
705
726
|
**opts,
|
|
706
727
|
)
|
|
707
728
|
|
|
@@ -159,23 +159,29 @@ class SandboxApi(SandboxBase):
|
|
|
159
159
|
secure: bool,
|
|
160
160
|
mcp: Optional[McpServer] = None,
|
|
161
161
|
network: Optional[SandboxNetworkOpts] = None,
|
|
162
|
+
volume_id: Optional[str] = None,
|
|
163
|
+
volume_mount_path: Optional[str] = None,
|
|
162
164
|
**opts: Unpack[ApiParams],
|
|
163
165
|
) -> SandboxCreateResponse:
|
|
164
166
|
config = ConnectionConfig(**opts)
|
|
165
167
|
|
|
166
168
|
api_client = get_api_client(config)
|
|
169
|
+
new_sandbox = NewSandbox(
|
|
170
|
+
template_id=template,
|
|
171
|
+
auto_pause=auto_pause,
|
|
172
|
+
metadata=metadata or {},
|
|
173
|
+
timeout=timeout,
|
|
174
|
+
env_vars=env_vars or {},
|
|
175
|
+
mcp=mcp or UNSET,
|
|
176
|
+
secure=secure,
|
|
177
|
+
allow_internet_access=allow_internet_access,
|
|
178
|
+
network=SandboxNetworkConfig(**network) if network else UNSET,
|
|
179
|
+
volume_id=volume_id or UNSET,
|
|
180
|
+
volume_mount_path=volume_mount_path or UNSET,
|
|
181
|
+
)
|
|
182
|
+
|
|
167
183
|
res = await post_sandboxes.asyncio_detailed(
|
|
168
|
-
body=
|
|
169
|
-
template_id=template,
|
|
170
|
-
auto_pause=auto_pause,
|
|
171
|
-
metadata=metadata or {},
|
|
172
|
-
timeout=timeout,
|
|
173
|
-
env_vars=env_vars or {},
|
|
174
|
-
mcp=mcp or UNSET,
|
|
175
|
-
secure=secure,
|
|
176
|
-
allow_internet_access=allow_internet_access,
|
|
177
|
-
network=SandboxNetworkConfig(**network) if network else UNSET,
|
|
178
|
-
),
|
|
184
|
+
body=new_sandbox,
|
|
179
185
|
client=api_client,
|
|
180
186
|
)
|
|
181
187
|
|
|
@@ -9,7 +9,11 @@ import httpx
|
|
|
9
9
|
from packaging.version import Version
|
|
10
10
|
|
|
11
11
|
from moru.envd.versions import ENVD_VERSION_RECURSIVE_WATCH, ENVD_DEFAULT_USER
|
|
12
|
-
from moru.exceptions import
|
|
12
|
+
from moru.exceptions import (
|
|
13
|
+
SandboxException,
|
|
14
|
+
TemplateException,
|
|
15
|
+
InvalidArgumentException,
|
|
16
|
+
)
|
|
13
17
|
from moru.connection_config import (
|
|
14
18
|
ConnectionConfig,
|
|
15
19
|
Username,
|
moru/sandbox_sync/main.py
CHANGED
|
@@ -16,6 +16,7 @@ from moru.exceptions import SandboxException, format_request_timeout_error
|
|
|
16
16
|
from moru.sandbox.main import SandboxOpts
|
|
17
17
|
from moru.sandbox.sandbox_api import McpServer, SandboxMetrics, SandboxNetworkOpts
|
|
18
18
|
from moru.sandbox.utils import class_method_variant
|
|
19
|
+
from moru.volume.volume_api import validate_mount_path
|
|
19
20
|
from moru.sandbox_sync.commands.command import Commands
|
|
20
21
|
from moru.sandbox_sync.commands.pty import Pty
|
|
21
22
|
from moru.sandbox_sync.filesystem.filesystem import Filesystem
|
|
@@ -151,6 +152,8 @@ class Sandbox(SandboxApi):
|
|
|
151
152
|
allow_internet_access: bool = True,
|
|
152
153
|
mcp: Optional[McpServer] = None,
|
|
153
154
|
network: Optional[SandboxNetworkOpts] = None,
|
|
155
|
+
volume_id: Optional[str] = None,
|
|
156
|
+
volume_mount_path: Optional[str] = None,
|
|
154
157
|
**opts: Unpack[ApiParams],
|
|
155
158
|
) -> Self:
|
|
156
159
|
"""
|
|
@@ -166,11 +169,23 @@ class Sandbox(SandboxApi):
|
|
|
166
169
|
:param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`.
|
|
167
170
|
:param mcp: MCP server to enable in the sandbox
|
|
168
171
|
:param network: Sandbox network configuration
|
|
172
|
+
:param volume_id: Volume ID to attach (e.g., vol_abc123). Requires volume_mount_path.
|
|
173
|
+
:param volume_mount_path: Mount path inside sandbox. Required if volume_id is provided.
|
|
174
|
+
Must start with /workspace/, /data/, /mnt/, or /volumes/.
|
|
175
|
+
Note: Mounting overlays any existing files/directories at the mount path.
|
|
169
176
|
|
|
170
177
|
:return: A Sandbox instance for the new sandbox
|
|
171
178
|
|
|
172
179
|
Use this method instead of using the constructor to create a new sandbox.
|
|
173
180
|
"""
|
|
181
|
+
# Validate volume parameters
|
|
182
|
+
if volume_id and not volume_mount_path:
|
|
183
|
+
raise ValueError("volume_mount_path is required when volume_id is provided")
|
|
184
|
+
if volume_mount_path and not volume_id:
|
|
185
|
+
raise ValueError("volume_id is required when volume_mount_path is provided")
|
|
186
|
+
if volume_mount_path:
|
|
187
|
+
validate_mount_path(volume_mount_path)
|
|
188
|
+
|
|
174
189
|
if not template and mcp is not None:
|
|
175
190
|
template = cls.default_mcp_template
|
|
176
191
|
elif not template:
|
|
@@ -186,6 +201,8 @@ class Sandbox(SandboxApi):
|
|
|
186
201
|
allow_internet_access=allow_internet_access,
|
|
187
202
|
mcp=mcp,
|
|
188
203
|
network=network,
|
|
204
|
+
volume_id=volume_id,
|
|
205
|
+
volume_mount_path=volume_mount_path,
|
|
189
206
|
**opts,
|
|
190
207
|
)
|
|
191
208
|
|
|
@@ -672,6 +689,8 @@ class Sandbox(SandboxApi):
|
|
|
672
689
|
allow_internet_access: bool,
|
|
673
690
|
mcp: Optional[McpServer] = None,
|
|
674
691
|
network: Optional[SandboxNetworkOpts] = None,
|
|
692
|
+
volume_id: Optional[str] = None,
|
|
693
|
+
volume_mount_path: Optional[str] = None,
|
|
675
694
|
**opts: Unpack[ApiParams],
|
|
676
695
|
) -> Self:
|
|
677
696
|
extra_sandbox_headers = {}
|
|
@@ -694,6 +713,8 @@ class Sandbox(SandboxApi):
|
|
|
694
713
|
allow_internet_access=allow_internet_access,
|
|
695
714
|
mcp=mcp,
|
|
696
715
|
network=network,
|
|
716
|
+
volume_id=volume_id,
|
|
717
|
+
volume_mount_path=volume_mount_path,
|
|
697
718
|
**opts,
|
|
698
719
|
)
|
|
699
720
|
|
moru/sandbox_sync/sandbox_api.py
CHANGED
|
@@ -158,23 +158,29 @@ class SandboxApi(SandboxBase):
|
|
|
158
158
|
secure: bool,
|
|
159
159
|
mcp: Optional[McpServer] = None,
|
|
160
160
|
network: Optional[SandboxNetworkOpts] = None,
|
|
161
|
+
volume_id: Optional[str] = None,
|
|
162
|
+
volume_mount_path: Optional[str] = None,
|
|
161
163
|
**opts: Unpack[ApiParams],
|
|
162
164
|
) -> SandboxCreateResponse:
|
|
163
165
|
config = ConnectionConfig(**opts)
|
|
164
166
|
|
|
165
167
|
api_client = get_api_client(config)
|
|
168
|
+
new_sandbox = NewSandbox(
|
|
169
|
+
template_id=template,
|
|
170
|
+
auto_pause=auto_pause,
|
|
171
|
+
metadata=metadata or {},
|
|
172
|
+
timeout=timeout,
|
|
173
|
+
env_vars=env_vars or {},
|
|
174
|
+
mcp=mcp or UNSET,
|
|
175
|
+
secure=secure,
|
|
176
|
+
allow_internet_access=allow_internet_access,
|
|
177
|
+
network=SandboxNetworkConfig(**network) if network else UNSET,
|
|
178
|
+
volume_id=volume_id or UNSET,
|
|
179
|
+
volume_mount_path=volume_mount_path or UNSET,
|
|
180
|
+
)
|
|
181
|
+
|
|
166
182
|
res = post_sandboxes.sync_detailed(
|
|
167
|
-
body=
|
|
168
|
-
template_id=template,
|
|
169
|
-
auto_pause=auto_pause,
|
|
170
|
-
metadata=metadata or {},
|
|
171
|
-
timeout=timeout,
|
|
172
|
-
env_vars=env_vars or {},
|
|
173
|
-
mcp=mcp or UNSET,
|
|
174
|
-
secure=secure,
|
|
175
|
-
allow_internet_access=allow_internet_access,
|
|
176
|
-
network=SandboxNetworkConfig(**network) if network else UNSET,
|
|
177
|
-
),
|
|
183
|
+
body=new_sandbox,
|
|
178
184
|
client=api_client,
|
|
179
185
|
)
|
|
180
186
|
|
moru/volume/__init__.py
ADDED
moru/volume/types.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Volume type definitions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileType(str, Enum):
|
|
10
|
+
"""Type of file system entry."""
|
|
11
|
+
|
|
12
|
+
FILE = "file"
|
|
13
|
+
DIRECTORY = "directory"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FileInfo:
|
|
18
|
+
"""Information about a file or directory in a volume."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
"""File or directory name."""
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
"""Full path within volume."""
|
|
25
|
+
|
|
26
|
+
type: FileType
|
|
27
|
+
"""Entry type (file or directory)."""
|
|
28
|
+
|
|
29
|
+
size: Optional[int] = None
|
|
30
|
+
"""File size in bytes (only for files)."""
|
|
31
|
+
|
|
32
|
+
modified_at: Optional[datetime] = None
|
|
33
|
+
"""Last modification time."""
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _from_api_response(cls, data: dict) -> "FileInfo":
|
|
37
|
+
"""Create FileInfo from API response data."""
|
|
38
|
+
return cls(
|
|
39
|
+
name=data["name"],
|
|
40
|
+
path=data["path"],
|
|
41
|
+
type=FileType(data["type"]),
|
|
42
|
+
size=data.get("size"),
|
|
43
|
+
modified_at=(
|
|
44
|
+
datetime.fromisoformat(data["modifiedAt"].replace("Z", "+00:00"))
|
|
45
|
+
if data.get("modifiedAt")
|
|
46
|
+
else None
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class VolumeInfo:
|
|
53
|
+
"""Information about a volume."""
|
|
54
|
+
|
|
55
|
+
volume_id: str
|
|
56
|
+
"""Unique volume identifier."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
"""Volume name."""
|
|
60
|
+
|
|
61
|
+
total_size_bytes: int
|
|
62
|
+
"""Total size of files in volume (bytes)."""
|
|
63
|
+
|
|
64
|
+
total_file_count: int
|
|
65
|
+
"""Total number of files in volume."""
|
|
66
|
+
|
|
67
|
+
created_at: datetime
|
|
68
|
+
"""When the volume was created."""
|
|
69
|
+
|
|
70
|
+
updated_at: datetime
|
|
71
|
+
"""When the volume was last updated."""
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _from_api_response(cls, data: dict) -> "VolumeInfo":
|
|
75
|
+
"""Create VolumeInfo from API response data."""
|
|
76
|
+
return cls(
|
|
77
|
+
volume_id=data["volumeID"],
|
|
78
|
+
name=data["name"],
|
|
79
|
+
total_size_bytes=data.get("totalSizeBytes", 0),
|
|
80
|
+
total_file_count=data.get("totalFileCount", 0),
|
|
81
|
+
created_at=datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")),
|
|
82
|
+
updated_at=datetime.fromisoformat(data["updatedAt"].replace("Z", "+00:00")),
|
|
83
|
+
)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Base VolumeApi class with static methods for volume operations."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from typing_extensions import Unpack
|
|
6
|
+
|
|
7
|
+
from moru.api import ApiClient, handle_api_exception
|
|
8
|
+
from moru.api.client_sync import get_api_client
|
|
9
|
+
from moru.connection_config import ApiParams, ConnectionConfig
|
|
10
|
+
from moru.exceptions import NotFoundException
|
|
11
|
+
|
|
12
|
+
from .types import FileInfo, VolumeInfo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Allowed mount path prefixes for sandbox volume attachment
|
|
16
|
+
ALLOWED_MOUNT_PREFIXES = ("/workspace/", "/data/", "/mnt/", "/volumes/")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_mount_path(path: str) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Validate volume mount path.
|
|
22
|
+
|
|
23
|
+
:param path: Mount path to validate
|
|
24
|
+
:raises ValueError: If path is invalid
|
|
25
|
+
|
|
26
|
+
Allowed prefixes: /workspace/, /data/, /mnt/, /volumes/
|
|
27
|
+
"""
|
|
28
|
+
if not path:
|
|
29
|
+
raise ValueError("Mount path cannot be empty")
|
|
30
|
+
|
|
31
|
+
if not path.startswith("/"):
|
|
32
|
+
raise ValueError("Mount path must be absolute (start with /)")
|
|
33
|
+
|
|
34
|
+
# Check allowed prefixes
|
|
35
|
+
if not any(path.startswith(prefix) for prefix in ALLOWED_MOUNT_PREFIXES):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Mount path must start with one of: {', '.join(ALLOWED_MOUNT_PREFIXES)}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Check for directory traversal
|
|
41
|
+
if ".." in path:
|
|
42
|
+
raise ValueError("Mount path cannot contain '..'")
|
|
43
|
+
|
|
44
|
+
# Check that path has a subdirectory after prefix
|
|
45
|
+
# e.g., /workspace is not valid, but /workspace/data is
|
|
46
|
+
for prefix in ALLOWED_MOUNT_PREFIXES:
|
|
47
|
+
if path.startswith(prefix):
|
|
48
|
+
remainder = path[len(prefix) :]
|
|
49
|
+
# Empty remainder means path is just the prefix without trailing content
|
|
50
|
+
# This is actually valid - /workspace/ should work
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class VolumeApi:
|
|
55
|
+
"""Base class for volume API operations."""
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _create_volume(
|
|
59
|
+
name: str,
|
|
60
|
+
**opts: Unpack[ApiParams],
|
|
61
|
+
) -> VolumeInfo:
|
|
62
|
+
"""
|
|
63
|
+
Create a new volume (idempotent).
|
|
64
|
+
|
|
65
|
+
:param name: Volume name
|
|
66
|
+
:return: Volume info
|
|
67
|
+
"""
|
|
68
|
+
config = ConnectionConfig(**opts)
|
|
69
|
+
api_client = get_api_client(config)
|
|
70
|
+
client = api_client.get_httpx_client()
|
|
71
|
+
|
|
72
|
+
response = client.post(
|
|
73
|
+
f"{config.api_url}/volumes",
|
|
74
|
+
json={"name": name},
|
|
75
|
+
timeout=config.request_timeout,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
err = handle_api_exception(response)
|
|
79
|
+
if err:
|
|
80
|
+
raise err
|
|
81
|
+
|
|
82
|
+
return VolumeInfo._from_api_response(response.json())
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _get_volume(
|
|
86
|
+
volume_id_or_name: str,
|
|
87
|
+
**opts: Unpack[ApiParams],
|
|
88
|
+
) -> VolumeInfo:
|
|
89
|
+
"""
|
|
90
|
+
Get volume by ID or name.
|
|
91
|
+
|
|
92
|
+
:param volume_id_or_name: Volume ID (vol_xxx) or name
|
|
93
|
+
:return: Volume info
|
|
94
|
+
"""
|
|
95
|
+
config = ConnectionConfig(**opts)
|
|
96
|
+
api_client = get_api_client(config)
|
|
97
|
+
client = api_client.get_httpx_client()
|
|
98
|
+
|
|
99
|
+
response = client.get(
|
|
100
|
+
f"{config.api_url}/volumes/{volume_id_or_name}",
|
|
101
|
+
timeout=config.request_timeout,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if response.status_code == 404:
|
|
105
|
+
raise NotFoundException(f"Volume '{volume_id_or_name}' not found")
|
|
106
|
+
|
|
107
|
+
err = handle_api_exception(response)
|
|
108
|
+
if err:
|
|
109
|
+
raise err
|
|
110
|
+
|
|
111
|
+
return VolumeInfo._from_api_response(response.json())
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _list_volumes(
|
|
115
|
+
limit: Optional[int] = None,
|
|
116
|
+
next_token: Optional[str] = None,
|
|
117
|
+
**opts: Unpack[ApiParams],
|
|
118
|
+
) -> Tuple[List[VolumeInfo], Optional[str]]:
|
|
119
|
+
"""
|
|
120
|
+
List all volumes.
|
|
121
|
+
|
|
122
|
+
:param limit: Maximum number of volumes to return
|
|
123
|
+
:param next_token: Pagination token
|
|
124
|
+
:return: Tuple of (volumes, next_token)
|
|
125
|
+
"""
|
|
126
|
+
config = ConnectionConfig(**opts)
|
|
127
|
+
api_client = get_api_client(config)
|
|
128
|
+
client = api_client.get_httpx_client()
|
|
129
|
+
|
|
130
|
+
params = {}
|
|
131
|
+
if limit is not None:
|
|
132
|
+
params["limit"] = limit
|
|
133
|
+
if next_token is not None:
|
|
134
|
+
params["nextToken"] = next_token
|
|
135
|
+
|
|
136
|
+
response = client.get(
|
|
137
|
+
f"{config.api_url}/volumes",
|
|
138
|
+
params=params,
|
|
139
|
+
timeout=config.request_timeout,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
err = handle_api_exception(response)
|
|
143
|
+
if err:
|
|
144
|
+
raise err
|
|
145
|
+
|
|
146
|
+
volumes = [VolumeInfo._from_api_response(v) for v in response.json()]
|
|
147
|
+
result_next_token = response.headers.get("x-next-token")
|
|
148
|
+
|
|
149
|
+
return volumes, result_next_token
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _delete_volume(
|
|
153
|
+
volume_id_or_name: str,
|
|
154
|
+
**opts: Unpack[ApiParams],
|
|
155
|
+
) -> bool:
|
|
156
|
+
"""
|
|
157
|
+
Delete a volume.
|
|
158
|
+
|
|
159
|
+
:param volume_id_or_name: Volume ID (vol_xxx) or name
|
|
160
|
+
:return: True if deleted, False if not found
|
|
161
|
+
"""
|
|
162
|
+
config = ConnectionConfig(**opts)
|
|
163
|
+
api_client = get_api_client(config)
|
|
164
|
+
client = api_client.get_httpx_client()
|
|
165
|
+
|
|
166
|
+
response = client.delete(
|
|
167
|
+
f"{config.api_url}/volumes/{volume_id_or_name}",
|
|
168
|
+
timeout=config.request_timeout,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if response.status_code == 404:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
err = handle_api_exception(response)
|
|
175
|
+
if err:
|
|
176
|
+
raise err
|
|
177
|
+
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _list_files(
|
|
182
|
+
volume_id: str,
|
|
183
|
+
path: str = "/",
|
|
184
|
+
limit: Optional[int] = None,
|
|
185
|
+
next_token: Optional[str] = None,
|
|
186
|
+
**opts: Unpack[ApiParams],
|
|
187
|
+
) -> Tuple[List[FileInfo], Optional[str]]:
|
|
188
|
+
"""
|
|
189
|
+
List files in a volume.
|
|
190
|
+
|
|
191
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
192
|
+
:param path: Directory path to list
|
|
193
|
+
:param limit: Maximum number of files to return
|
|
194
|
+
:param next_token: Pagination token
|
|
195
|
+
:return: Tuple of (files, next_token)
|
|
196
|
+
"""
|
|
197
|
+
config = ConnectionConfig(**opts)
|
|
198
|
+
api_client = get_api_client(config)
|
|
199
|
+
client = api_client.get_httpx_client()
|
|
200
|
+
|
|
201
|
+
params = {"path": path}
|
|
202
|
+
if limit is not None:
|
|
203
|
+
params["limit"] = limit
|
|
204
|
+
if next_token is not None:
|
|
205
|
+
params["nextToken"] = next_token
|
|
206
|
+
|
|
207
|
+
response = client.get(
|
|
208
|
+
f"{config.api_url}/volumes/{volume_id}/files",
|
|
209
|
+
params=params,
|
|
210
|
+
timeout=config.request_timeout,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if response.status_code == 404:
|
|
214
|
+
raise NotFoundException(f"Volume '{volume_id}' not found")
|
|
215
|
+
|
|
216
|
+
err = handle_api_exception(response)
|
|
217
|
+
if err:
|
|
218
|
+
raise err
|
|
219
|
+
|
|
220
|
+
data = response.json()
|
|
221
|
+
files = [FileInfo._from_api_response(f) for f in data.get("files", [])]
|
|
222
|
+
result_next_token = data.get("nextToken")
|
|
223
|
+
|
|
224
|
+
return files, result_next_token
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _upload_file(
|
|
228
|
+
volume_id: str,
|
|
229
|
+
path: str,
|
|
230
|
+
content: bytes,
|
|
231
|
+
**opts: Unpack[ApiParams],
|
|
232
|
+
) -> int:
|
|
233
|
+
"""
|
|
234
|
+
Upload file content to volume.
|
|
235
|
+
|
|
236
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
237
|
+
:param path: Destination path in volume
|
|
238
|
+
:param content: File content as bytes
|
|
239
|
+
:return: Size of uploaded file
|
|
240
|
+
"""
|
|
241
|
+
config = ConnectionConfig(**opts)
|
|
242
|
+
api_client = get_api_client(config)
|
|
243
|
+
client = api_client.get_httpx_client()
|
|
244
|
+
|
|
245
|
+
response = client.put(
|
|
246
|
+
f"{config.api_url}/volumes/{volume_id}/files/upload",
|
|
247
|
+
params={"path": path},
|
|
248
|
+
content=content,
|
|
249
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
250
|
+
timeout=config.request_timeout,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if response.status_code == 404:
|
|
254
|
+
raise NotFoundException(f"Volume '{volume_id}' not found")
|
|
255
|
+
|
|
256
|
+
err = handle_api_exception(response)
|
|
257
|
+
if err:
|
|
258
|
+
raise err
|
|
259
|
+
|
|
260
|
+
return response.json().get("size", len(content))
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _download_file(
|
|
264
|
+
volume_id: str,
|
|
265
|
+
path: str,
|
|
266
|
+
**opts: Unpack[ApiParams],
|
|
267
|
+
) -> bytes:
|
|
268
|
+
"""
|
|
269
|
+
Download file content from volume.
|
|
270
|
+
|
|
271
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
272
|
+
:param path: File path in volume
|
|
273
|
+
:return: File content as bytes
|
|
274
|
+
"""
|
|
275
|
+
config = ConnectionConfig(**opts)
|
|
276
|
+
api_client = get_api_client(config)
|
|
277
|
+
client = api_client.get_httpx_client()
|
|
278
|
+
|
|
279
|
+
response = client.get(
|
|
280
|
+
f"{config.api_url}/volumes/{volume_id}/files/download",
|
|
281
|
+
params={"path": path},
|
|
282
|
+
timeout=config.request_timeout,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if response.status_code == 404:
|
|
286
|
+
raise NotFoundException(f"Volume '{volume_id}' or file '{path}' not found")
|
|
287
|
+
|
|
288
|
+
err = handle_api_exception(response)
|
|
289
|
+
if err:
|
|
290
|
+
raise err
|
|
291
|
+
|
|
292
|
+
return response.content
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _delete_file(
|
|
296
|
+
volume_id: str,
|
|
297
|
+
path: str,
|
|
298
|
+
recursive: bool = False,
|
|
299
|
+
**opts: Unpack[ApiParams],
|
|
300
|
+
) -> bool:
|
|
301
|
+
"""
|
|
302
|
+
Delete file or directory from volume.
|
|
303
|
+
|
|
304
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
305
|
+
:param path: Path to delete
|
|
306
|
+
:param recursive: Delete directory recursively
|
|
307
|
+
:return: True if deleted
|
|
308
|
+
"""
|
|
309
|
+
config = ConnectionConfig(**opts)
|
|
310
|
+
api_client = get_api_client(config)
|
|
311
|
+
client = api_client.get_httpx_client()
|
|
312
|
+
|
|
313
|
+
params: dict = {"path": path}
|
|
314
|
+
if recursive:
|
|
315
|
+
params["recursive"] = "true"
|
|
316
|
+
|
|
317
|
+
response = client.delete(
|
|
318
|
+
f"{config.api_url}/volumes/{volume_id}/files",
|
|
319
|
+
params=params,
|
|
320
|
+
timeout=config.request_timeout,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if response.status_code == 404:
|
|
324
|
+
raise NotFoundException(f"Volume '{volume_id}' or path '{path}' not found")
|
|
325
|
+
|
|
326
|
+
err = handle_api_exception(response)
|
|
327
|
+
if err:
|
|
328
|
+
raise err
|
|
329
|
+
|
|
330
|
+
return True
|