moru 0.1.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.
Files changed (152) hide show
  1. moru/__init__.py +174 -0
  2. moru/api/__init__.py +164 -0
  3. moru/api/client/__init__.py +8 -0
  4. moru/api/client/api/__init__.py +1 -0
  5. moru/api/client/api/sandboxes/__init__.py +1 -0
  6. moru/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py +161 -0
  7. moru/api/client/api/sandboxes/get_sandboxes.py +176 -0
  8. moru/api/client/api/sandboxes/get_sandboxes_metrics.py +173 -0
  9. moru/api/client/api/sandboxes/get_sandboxes_sandbox_id.py +163 -0
  10. moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py +199 -0
  11. moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py +212 -0
  12. moru/api/client/api/sandboxes/get_v2_sandboxes.py +230 -0
  13. moru/api/client/api/sandboxes/post_sandboxes.py +172 -0
  14. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py +193 -0
  15. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py +165 -0
  16. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py +181 -0
  17. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py +189 -0
  18. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py +193 -0
  19. moru/api/client/api/templates/__init__.py +1 -0
  20. moru/api/client/api/templates/delete_templates_template_id.py +157 -0
  21. moru/api/client/api/templates/get_templates.py +172 -0
  22. moru/api/client/api/templates/get_templates_template_id.py +195 -0
  23. moru/api/client/api/templates/get_templates_template_id_builds_build_id_status.py +217 -0
  24. moru/api/client/api/templates/get_templates_template_id_files_hash.py +180 -0
  25. moru/api/client/api/templates/patch_templates_template_id.py +183 -0
  26. moru/api/client/api/templates/post_templates.py +172 -0
  27. moru/api/client/api/templates/post_templates_template_id.py +181 -0
  28. moru/api/client/api/templates/post_templates_template_id_builds_build_id.py +170 -0
  29. moru/api/client/api/templates/post_v2_templates.py +172 -0
  30. moru/api/client/api/templates/post_v3_templates.py +172 -0
  31. moru/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py +192 -0
  32. moru/api/client/client.py +286 -0
  33. moru/api/client/errors.py +16 -0
  34. moru/api/client/models/__init__.py +123 -0
  35. moru/api/client/models/aws_registry.py +85 -0
  36. moru/api/client/models/aws_registry_type.py +8 -0
  37. moru/api/client/models/build_log_entry.py +89 -0
  38. moru/api/client/models/build_status_reason.py +95 -0
  39. moru/api/client/models/connect_sandbox.py +59 -0
  40. moru/api/client/models/created_access_token.py +100 -0
  41. moru/api/client/models/created_team_api_key.py +166 -0
  42. moru/api/client/models/disk_metrics.py +91 -0
  43. moru/api/client/models/error.py +67 -0
  44. moru/api/client/models/gcp_registry.py +69 -0
  45. moru/api/client/models/gcp_registry_type.py +8 -0
  46. moru/api/client/models/general_registry.py +77 -0
  47. moru/api/client/models/general_registry_type.py +8 -0
  48. moru/api/client/models/identifier_masking_details.py +83 -0
  49. moru/api/client/models/listed_sandbox.py +154 -0
  50. moru/api/client/models/log_level.py +11 -0
  51. moru/api/client/models/max_team_metric.py +78 -0
  52. moru/api/client/models/mcp_type_0.py +44 -0
  53. moru/api/client/models/new_access_token.py +59 -0
  54. moru/api/client/models/new_sandbox.py +172 -0
  55. moru/api/client/models/new_team_api_key.py +59 -0
  56. moru/api/client/models/node.py +155 -0
  57. moru/api/client/models/node_detail.py +165 -0
  58. moru/api/client/models/node_metrics.py +122 -0
  59. moru/api/client/models/node_status.py +11 -0
  60. moru/api/client/models/node_status_change.py +79 -0
  61. moru/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py +59 -0
  62. moru/api/client/models/post_sandboxes_sandbox_id_timeout_body.py +59 -0
  63. moru/api/client/models/resumed_sandbox.py +68 -0
  64. moru/api/client/models/sandbox.py +145 -0
  65. moru/api/client/models/sandbox_detail.py +183 -0
  66. moru/api/client/models/sandbox_log.py +70 -0
  67. moru/api/client/models/sandbox_log_entry.py +93 -0
  68. moru/api/client/models/sandbox_log_entry_fields.py +44 -0
  69. moru/api/client/models/sandbox_logs.py +91 -0
  70. moru/api/client/models/sandbox_metric.py +118 -0
  71. moru/api/client/models/sandbox_network_config.py +92 -0
  72. moru/api/client/models/sandbox_state.py +9 -0
  73. moru/api/client/models/sandboxes_with_metrics.py +59 -0
  74. moru/api/client/models/team.py +83 -0
  75. moru/api/client/models/team_api_key.py +158 -0
  76. moru/api/client/models/team_metric.py +86 -0
  77. moru/api/client/models/team_user.py +68 -0
  78. moru/api/client/models/template.py +217 -0
  79. moru/api/client/models/template_build.py +139 -0
  80. moru/api/client/models/template_build_file_upload.py +70 -0
  81. moru/api/client/models/template_build_info.py +126 -0
  82. moru/api/client/models/template_build_request.py +115 -0
  83. moru/api/client/models/template_build_request_v2.py +88 -0
  84. moru/api/client/models/template_build_request_v3.py +88 -0
  85. moru/api/client/models/template_build_start_v2.py +184 -0
  86. moru/api/client/models/template_build_status.py +11 -0
  87. moru/api/client/models/template_legacy.py +207 -0
  88. moru/api/client/models/template_request_response_v3.py +83 -0
  89. moru/api/client/models/template_step.py +91 -0
  90. moru/api/client/models/template_update_request.py +59 -0
  91. moru/api/client/models/template_with_builds.py +148 -0
  92. moru/api/client/models/update_team_api_key.py +59 -0
  93. moru/api/client/py.typed +1 -0
  94. moru/api/client/types.py +54 -0
  95. moru/api/client_async/__init__.py +50 -0
  96. moru/api/client_sync/__init__.py +52 -0
  97. moru/api/metadata.py +14 -0
  98. moru/connection_config.py +217 -0
  99. moru/envd/api.py +59 -0
  100. moru/envd/filesystem/filesystem_connect.py +193 -0
  101. moru/envd/filesystem/filesystem_pb2.py +76 -0
  102. moru/envd/filesystem/filesystem_pb2.pyi +233 -0
  103. moru/envd/process/process_connect.py +155 -0
  104. moru/envd/process/process_pb2.py +92 -0
  105. moru/envd/process/process_pb2.pyi +304 -0
  106. moru/envd/rpc.py +61 -0
  107. moru/envd/versions.py +6 -0
  108. moru/exceptions.py +95 -0
  109. moru/sandbox/commands/command_handle.py +69 -0
  110. moru/sandbox/commands/main.py +39 -0
  111. moru/sandbox/filesystem/filesystem.py +94 -0
  112. moru/sandbox/filesystem/watch_handle.py +60 -0
  113. moru/sandbox/main.py +210 -0
  114. moru/sandbox/mcp.py +1120 -0
  115. moru/sandbox/network.py +8 -0
  116. moru/sandbox/sandbox_api.py +210 -0
  117. moru/sandbox/signature.py +45 -0
  118. moru/sandbox/utils.py +34 -0
  119. moru/sandbox_async/commands/command.py +336 -0
  120. moru/sandbox_async/commands/command_handle.py +196 -0
  121. moru/sandbox_async/commands/pty.py +240 -0
  122. moru/sandbox_async/filesystem/filesystem.py +531 -0
  123. moru/sandbox_async/filesystem/watch_handle.py +62 -0
  124. moru/sandbox_async/main.py +734 -0
  125. moru/sandbox_async/paginator.py +69 -0
  126. moru/sandbox_async/sandbox_api.py +325 -0
  127. moru/sandbox_async/utils.py +7 -0
  128. moru/sandbox_sync/commands/command.py +328 -0
  129. moru/sandbox_sync/commands/command_handle.py +150 -0
  130. moru/sandbox_sync/commands/pty.py +230 -0
  131. moru/sandbox_sync/filesystem/filesystem.py +518 -0
  132. moru/sandbox_sync/filesystem/watch_handle.py +69 -0
  133. moru/sandbox_sync/main.py +726 -0
  134. moru/sandbox_sync/paginator.py +69 -0
  135. moru/sandbox_sync/sandbox_api.py +308 -0
  136. moru/template/consts.py +30 -0
  137. moru/template/dockerfile_parser.py +275 -0
  138. moru/template/logger.py +232 -0
  139. moru/template/main.py +1360 -0
  140. moru/template/readycmd.py +138 -0
  141. moru/template/types.py +105 -0
  142. moru/template/utils.py +320 -0
  143. moru/template_async/build_api.py +202 -0
  144. moru/template_async/main.py +366 -0
  145. moru/template_sync/build_api.py +199 -0
  146. moru/template_sync/main.py +371 -0
  147. moru-0.1.0.dist-info/METADATA +63 -0
  148. moru-0.1.0.dist-info/RECORD +152 -0
  149. moru-0.1.0.dist-info/WHEEL +4 -0
  150. moru-0.1.0.dist-info/licenses/LICENSE +9 -0
  151. moru_connect/__init__.py +1 -0
  152. moru_connect/client.py +493 -0
@@ -0,0 +1,69 @@
1
+ import urllib.parse
2
+ from typing import Optional, List
3
+
4
+ from moru.api import handle_api_exception
5
+ from moru.api.client.api.sandboxes import get_v2_sandboxes
6
+ from moru.api.client.models.error import Error
7
+ from moru.api.client.types import UNSET
8
+ from moru.exceptions import SandboxException
9
+ from moru.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo
10
+ from moru.api.client_sync import get_api_client
11
+
12
+
13
+ class SandboxPaginator(SandboxPaginatorBase):
14
+ """
15
+ Paginator for listing sandboxes.
16
+
17
+ Example:
18
+ ```python
19
+ paginator = Sandbox.list()
20
+
21
+ while paginator.has_next:
22
+ sandboxes = paginator.next_items()
23
+ print(sandboxes)
24
+ ```
25
+ """
26
+
27
+ def next_items(self) -> List[SandboxInfo]:
28
+ """
29
+ Returns the next page of sandboxes.
30
+
31
+ Call this method only if `has_next` is `True`, otherwise it will raise an exception.
32
+
33
+ :returns: List of sandboxes
34
+ """
35
+ if not self.has_next:
36
+ raise Exception("No more items to fetch")
37
+
38
+ # Convert filters to the format expected by the API
39
+ metadata: Optional[str] = None
40
+ if self.query and self.query.metadata:
41
+ quoted_metadata = {
42
+ urllib.parse.quote(k): urllib.parse.quote(v)
43
+ for k, v in self.query.metadata.items()
44
+ }
45
+ metadata = urllib.parse.urlencode(quoted_metadata)
46
+
47
+ api_client = get_api_client(self._config)
48
+ res = get_v2_sandboxes.sync_detailed(
49
+ client=api_client,
50
+ metadata=metadata if metadata else UNSET,
51
+ state=self.query.state if self.query and self.query.state else UNSET,
52
+ limit=self.limit if self.limit else UNSET,
53
+ next_token=self._next_token if self._next_token else UNSET,
54
+ )
55
+
56
+ if res.status_code >= 300:
57
+ raise handle_api_exception(res)
58
+
59
+ self._next_token = res.headers.get("x-next-token")
60
+ self._has_next = bool(self._next_token)
61
+
62
+ if res.parsed is None:
63
+ return []
64
+
65
+ # Check if res.parse is Error
66
+ if isinstance(res.parsed, Error):
67
+ raise SandboxException(f"{res.parsed.message}: Request failed")
68
+
69
+ return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed]
@@ -0,0 +1,308 @@
1
+ import datetime
2
+ from typing import Dict, List, Optional
3
+
4
+ from packaging.version import Version
5
+ from typing_extensions import Unpack
6
+
7
+ from moru.api import SandboxCreateResponse, handle_api_exception
8
+ from moru.api.client.api.sandboxes import (
9
+ delete_sandboxes_sandbox_id,
10
+ get_sandboxes_sandbox_id,
11
+ get_sandboxes_sandbox_id_metrics,
12
+ post_sandboxes,
13
+ post_sandboxes_sandbox_id_connect,
14
+ post_sandboxes_sandbox_id_pause,
15
+ post_sandboxes_sandbox_id_timeout,
16
+ )
17
+ from moru.api.client.models import (
18
+ ConnectSandbox,
19
+ Error,
20
+ NewSandbox,
21
+ PostSandboxesSandboxIDTimeoutBody,
22
+ Sandbox,
23
+ SandboxNetworkConfig,
24
+ )
25
+ from moru.api.client.types import UNSET
26
+ from moru.connection_config import ApiParams, ConnectionConfig
27
+ from moru.exceptions import NotFoundException, SandboxException, TemplateException
28
+ from moru.sandbox.main import SandboxBase
29
+ from moru.sandbox.sandbox_api import (
30
+ McpServer,
31
+ SandboxInfo,
32
+ SandboxMetrics,
33
+ SandboxNetworkOpts,
34
+ SandboxQuery,
35
+ )
36
+ from moru.sandbox_sync.paginator import SandboxPaginator, get_api_client
37
+
38
+
39
+ class SandboxApi(SandboxBase):
40
+ @staticmethod
41
+ def list(
42
+ query: Optional[SandboxQuery] = None,
43
+ limit: Optional[int] = None,
44
+ next_token: Optional[str] = None,
45
+ **opts: Unpack[ApiParams],
46
+ ) -> SandboxPaginator:
47
+ """
48
+ List all running sandboxes.
49
+
50
+ :param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxListQuery(metadata={"key": "value"})` or `SandboxListQuery(state=[SandboxState.RUNNING])`
51
+ :param limit: Maximum number of sandboxes to return per page
52
+ :param next_token: Token for pagination
53
+
54
+ :return: List of running sandboxes
55
+ """
56
+ return SandboxPaginator(
57
+ query=query,
58
+ limit=limit,
59
+ next_token=next_token,
60
+ **opts,
61
+ )
62
+
63
+ @classmethod
64
+ def _cls_get_info(
65
+ cls,
66
+ sandbox_id: str,
67
+ **opts: Unpack[ApiParams],
68
+ ) -> SandboxInfo:
69
+ """
70
+ Get the sandbox info.
71
+ :param sandbox_id: Sandbox ID
72
+
73
+ :return: Sandbox info
74
+ """
75
+ config = ConnectionConfig(**opts)
76
+
77
+ api_client = get_api_client(config)
78
+ res = get_sandboxes_sandbox_id.sync_detailed(
79
+ sandbox_id,
80
+ client=api_client,
81
+ )
82
+
83
+ if res.status_code == 404:
84
+ raise NotFoundException(f"Sandbox {sandbox_id} not found")
85
+
86
+ if res.status_code >= 300:
87
+ raise handle_api_exception(res)
88
+
89
+ if res.parsed is None:
90
+ raise SandboxException("Body of the request is None")
91
+
92
+ if isinstance(res.parsed, Error):
93
+ raise SandboxException(f"{res.parsed.message}: Request failed")
94
+
95
+ return SandboxInfo._from_sandbox_detail(res.parsed)
96
+
97
+ @classmethod
98
+ def _cls_kill(
99
+ cls,
100
+ sandbox_id: str,
101
+ **opts: Unpack[ApiParams],
102
+ ) -> bool:
103
+ config = ConnectionConfig(**opts)
104
+
105
+ if config.debug:
106
+ # Skip killing the sandbox in debug mode
107
+ return True
108
+
109
+ api_client = get_api_client(config)
110
+ res = delete_sandboxes_sandbox_id.sync_detailed(
111
+ sandbox_id,
112
+ client=api_client,
113
+ )
114
+
115
+ if res.status_code == 404:
116
+ return False
117
+
118
+ if res.status_code >= 300:
119
+ raise handle_api_exception(res)
120
+
121
+ return True
122
+
123
+ @classmethod
124
+ def _cls_set_timeout(
125
+ cls,
126
+ sandbox_id: str,
127
+ timeout: int,
128
+ **opts: Unpack[ApiParams],
129
+ ) -> None:
130
+ config = ConnectionConfig(**opts)
131
+
132
+ if config.debug:
133
+ # Skip setting timeout in debug mode
134
+ return
135
+
136
+ api_client = get_api_client(config)
137
+ res = post_sandboxes_sandbox_id_timeout.sync_detailed(
138
+ sandbox_id,
139
+ client=api_client,
140
+ body=PostSandboxesSandboxIDTimeoutBody(timeout=timeout),
141
+ )
142
+
143
+ if res.status_code == 404:
144
+ raise NotFoundException(f"Sandbox {sandbox_id} not found")
145
+
146
+ if res.status_code >= 300:
147
+ raise handle_api_exception(res)
148
+
149
+ @classmethod
150
+ def _create_sandbox(
151
+ cls,
152
+ template: str,
153
+ timeout: int,
154
+ auto_pause: bool,
155
+ allow_internet_access: bool,
156
+ metadata: Optional[Dict[str, str]],
157
+ env_vars: Optional[Dict[str, str]],
158
+ secure: bool,
159
+ mcp: Optional[McpServer] = None,
160
+ network: Optional[SandboxNetworkOpts] = None,
161
+ **opts: Unpack[ApiParams],
162
+ ) -> SandboxCreateResponse:
163
+ config = ConnectionConfig(**opts)
164
+
165
+ api_client = get_api_client(config)
166
+ res = post_sandboxes.sync_detailed(
167
+ body=NewSandbox(
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
+ ),
178
+ client=api_client,
179
+ )
180
+
181
+ if res.status_code >= 300:
182
+ raise handle_api_exception(res)
183
+
184
+ if res.parsed is None:
185
+ raise Exception("Body of the request is None")
186
+
187
+ if isinstance(res.parsed, Error):
188
+ raise SandboxException(f"{res.parsed.message}: Request failed")
189
+
190
+ if Version(res.parsed.envd_version) < Version("0.1.0"):
191
+ SandboxApi._cls_kill(res.parsed.sandbox_id)
192
+ raise TemplateException(
193
+ "You need to update the template to use the new SDK. "
194
+ "You can do this by running `moru template build` in the directory with the template."
195
+ )
196
+
197
+ return SandboxCreateResponse(
198
+ sandbox_id=res.parsed.sandbox_id,
199
+ sandbox_domain=res.parsed.domain,
200
+ envd_version=res.parsed.envd_version,
201
+ envd_access_token=res.parsed.envd_access_token,
202
+ traffic_access_token=res.parsed.traffic_access_token,
203
+ )
204
+
205
+ @classmethod
206
+ def _cls_get_metrics(
207
+ cls,
208
+ sandbox_id: str,
209
+ start: Optional[datetime.datetime] = None,
210
+ end: Optional[datetime.datetime] = None,
211
+ **opts: Unpack[ApiParams],
212
+ ) -> List[SandboxMetrics]:
213
+ config = ConnectionConfig(**opts)
214
+
215
+ if config.debug:
216
+ # Skip getting the metrics in debug mode
217
+ return []
218
+
219
+ api_client = get_api_client(config)
220
+ res = get_sandboxes_sandbox_id_metrics.sync_detailed(
221
+ sandbox_id,
222
+ start=int(start.timestamp() * 1000) if start else None,
223
+ end=int(end.timestamp() * 1000) if end else None,
224
+ client=api_client,
225
+ )
226
+
227
+ if res.status_code >= 300:
228
+ raise handle_api_exception(res)
229
+
230
+ if res.parsed is None:
231
+ return []
232
+
233
+ if isinstance(res.parsed, Error):
234
+ raise SandboxException(f"{res.parsed.message}: Request failed")
235
+
236
+ # Convert to typed SandboxMetrics objects
237
+ return [
238
+ SandboxMetrics(
239
+ cpu_count=metric.cpu_count,
240
+ cpu_used_pct=metric.cpu_used_pct,
241
+ disk_total=metric.disk_total,
242
+ disk_used=metric.disk_used,
243
+ mem_total=metric.mem_total,
244
+ mem_used=metric.mem_used,
245
+ timestamp=metric.timestamp,
246
+ )
247
+ for metric in res.parsed
248
+ ]
249
+
250
+ @classmethod
251
+ def _cls_connect(
252
+ cls,
253
+ sandbox_id: str,
254
+ timeout: Optional[int] = None,
255
+ **opts: Unpack[ApiParams],
256
+ ) -> Sandbox:
257
+ timeout = timeout or SandboxBase.default_sandbox_timeout
258
+
259
+ config = ConnectionConfig(**opts)
260
+
261
+ api_client = get_api_client(
262
+ config,
263
+ headers={
264
+ "Moru-Sandbox-Id": sandbox_id,
265
+ "Moru-Sandbox-Port": str(config.envd_port),
266
+ },
267
+ )
268
+ res = post_sandboxes_sandbox_id_connect.sync_detailed(
269
+ sandbox_id,
270
+ client=api_client,
271
+ body=ConnectSandbox(timeout=timeout),
272
+ )
273
+
274
+ if res.status_code == 404:
275
+ raise NotFoundException(f"Paused sandbox {sandbox_id} not found")
276
+
277
+ if res.status_code >= 300:
278
+ raise handle_api_exception(res)
279
+
280
+ if isinstance(res.parsed, Error):
281
+ raise SandboxException(f"{res.parsed.message}: Request failed")
282
+
283
+ return res.parsed
284
+
285
+ @classmethod
286
+ def _cls_pause(
287
+ cls,
288
+ sandbox_id: str,
289
+ **opts: Unpack[ApiParams],
290
+ ) -> str:
291
+ config = ConnectionConfig(**opts)
292
+
293
+ api_client = get_api_client(config)
294
+ res = post_sandboxes_sandbox_id_pause.sync_detailed(
295
+ sandbox_id,
296
+ client=api_client,
297
+ )
298
+
299
+ if res.status_code == 404:
300
+ raise NotFoundException(f"Sandbox {sandbox_id} not found")
301
+
302
+ if res.status_code == 409:
303
+ return sandbox_id
304
+
305
+ if res.status_code >= 300:
306
+ raise handle_api_exception(res)
307
+
308
+ return sandbox_id
@@ -0,0 +1,30 @@
1
+ """
2
+ Special step name for the finalization phase of template building.
3
+ This is the last step that runs after all user-defined instructions.
4
+ """
5
+
6
+ FINALIZE_STEP_NAME = "finalize"
7
+
8
+ """
9
+ Special step name for the base image phase of template building.
10
+ This is the first step that sets up the base image.
11
+ """
12
+ BASE_STEP_NAME = "base"
13
+
14
+ """
15
+ Stack trace depth for capturing caller information.
16
+
17
+ Depth levels:
18
+ 1. TemplateClass
19
+ 2. Caller method (e.g., copy(), from_image(), etc.)
20
+
21
+ This depth is used to determine the original caller's location
22
+ for stack traces.
23
+ """
24
+ STACK_TRACE_DEPTH = 2
25
+
26
+ """
27
+ Default setting for whether to resolve symbolic links when copying files.
28
+ When False, symlinks are copied as symlinks rather than following them.
29
+ """
30
+ RESOLVE_SYMLINKS = False
@@ -0,0 +1,275 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import tempfile
5
+ from typing import Dict, List, Optional, Protocol, Union, Literal
6
+
7
+ from dockerfile_parse import DockerfileParser
8
+ from moru.template.types import CopyItem
9
+
10
+
11
+ class DockerfFileFinalParserInterface(Protocol):
12
+ """Protocol defining the final interface for Dockerfile parsing callbacks."""
13
+
14
+
15
+ class DockerfileParserInterface(Protocol):
16
+ """Protocol defining the interface for Dockerfile parsing callbacks."""
17
+
18
+ def run_cmd(
19
+ self, command: Union[str, List[str]], user: Optional[str] = None
20
+ ) -> "DockerfileParserInterface":
21
+ """Handle RUN instruction."""
22
+ ...
23
+
24
+ def copy(
25
+ self,
26
+ src: Union[str, List[CopyItem]],
27
+ dest: Optional[str] = None,
28
+ force_upload: Optional[Literal[True]] = None,
29
+ resolve_symlinks: Optional[bool] = None,
30
+ user: Optional[str] = None,
31
+ mode: Optional[int] = None,
32
+ ) -> "DockerfileParserInterface":
33
+ """Handle COPY instruction."""
34
+ ...
35
+
36
+ def set_workdir(self, workdir: str) -> "DockerfileParserInterface":
37
+ """Handle WORKDIR instruction."""
38
+ ...
39
+
40
+ def set_user(self, user: str) -> "DockerfileParserInterface":
41
+ """Handle USER instruction."""
42
+ ...
43
+
44
+ def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface":
45
+ """Handle ENV instruction."""
46
+ ...
47
+
48
+ def set_start_cmd(
49
+ self, start_cmd: str, ready_cmd: str
50
+ ) -> "DockerfFileFinalParserInterface":
51
+ """Handle CMD/ENTRYPOINT instruction."""
52
+ ...
53
+
54
+
55
+ def parse_dockerfile(
56
+ dockerfile_content_or_path: str, template_builder: DockerfileParserInterface
57
+ ) -> str:
58
+ """
59
+ Parse a Dockerfile and convert it to Template SDK format.
60
+
61
+ :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file
62
+ :param template_builder: Interface providing template builder methods
63
+
64
+ :return: The base image from the Dockerfile
65
+
66
+ :raises ValueError: If the Dockerfile is invalid or unsupported
67
+ """
68
+ # Check if input is a file path that exists
69
+ if os.path.isfile(dockerfile_content_or_path):
70
+ # Read the file content
71
+ with open(dockerfile_content_or_path, "r", encoding="utf-8") as f:
72
+ dockerfile_content = f.read()
73
+ else:
74
+ # Treat as content directly
75
+ dockerfile_content = dockerfile_content_or_path
76
+
77
+ # Use a temporary directory to avoid creating files in the current directory
78
+ with tempfile.TemporaryDirectory() as temp_dir:
79
+ # Create a temporary Dockerfile
80
+ dockerfile_path = os.path.join(temp_dir, "Dockerfile")
81
+ with open(dockerfile_path, "w") as f:
82
+ f.write(dockerfile_content)
83
+
84
+ dfp = DockerfileParser(path=temp_dir)
85
+
86
+ # Check for multi-stage builds
87
+ from_instructions = [
88
+ instruction
89
+ for instruction in dfp.structure
90
+ if instruction["instruction"] == "FROM"
91
+ ]
92
+
93
+ if len(from_instructions) > 1:
94
+ raise ValueError("Multi-stage Dockerfiles are not supported")
95
+
96
+ if len(from_instructions) == 0:
97
+ raise ValueError("Dockerfile must contain a FROM instruction")
98
+
99
+ # Set the base image from the first FROM instruction
100
+ base_image = from_instructions[0]["value"]
101
+ # Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18")
102
+ if " as " in base_image.lower():
103
+ base_image = base_image.split(" as ")[0].strip()
104
+
105
+ user_changed = False
106
+ workdir_changed = False
107
+
108
+ # Set the user and workdir to the Docker defaults
109
+ template_builder.set_user("root")
110
+ template_builder.set_workdir("/")
111
+
112
+ # Process all other instructions
113
+ for instruction_data in dfp.structure:
114
+ instruction = instruction_data["instruction"]
115
+ value = instruction_data["value"]
116
+
117
+ if instruction == "FROM":
118
+ # Already handled above
119
+ continue
120
+ elif instruction == "RUN":
121
+ _handle_run_instruction(value, template_builder)
122
+ elif instruction in ["COPY", "ADD"]:
123
+ _handle_copy_instruction(value, template_builder)
124
+ elif instruction == "WORKDIR":
125
+ _handle_workdir_instruction(value, template_builder)
126
+ workdir_changed = True
127
+ elif instruction == "USER":
128
+ _handle_user_instruction(value, template_builder)
129
+ user_changed = True
130
+ elif instruction in ["ENV", "ARG"]:
131
+ _handle_env_instruction(value, instruction, template_builder)
132
+ elif instruction in ["CMD", "ENTRYPOINT"]:
133
+ _handle_cmd_entrypoint_instruction(value, template_builder)
134
+ else:
135
+ print(f"Unsupported instruction: {instruction}")
136
+ continue
137
+
138
+ # Set the user and workdir to the Moru defaults
139
+ if not user_changed:
140
+ template_builder.set_user("user")
141
+ if not workdir_changed:
142
+ template_builder.set_workdir("/home/user")
143
+
144
+ return base_image
145
+
146
+
147
+ def _handle_run_instruction(
148
+ value: str, template_builder: DockerfileParserInterface
149
+ ) -> None:
150
+ """Handle RUN instruction"""
151
+ if not value.strip():
152
+ return
153
+ # Remove line continuations and normalize whitespace
154
+ command = re.sub(r"\\\s*\n\s*", " ", value).strip()
155
+ template_builder.run_cmd(command)
156
+
157
+
158
+ def _handle_copy_instruction(
159
+ value: str, template_builder: DockerfileParserInterface
160
+ ) -> None:
161
+ """Handle COPY/ADD instruction"""
162
+ if not value.strip():
163
+ return
164
+ # Parse source and destination from COPY/ADD command
165
+ # Handle both quoted and unquoted paths
166
+ parts = []
167
+ current_part = ""
168
+ in_quotes = False
169
+ quote_char = None
170
+
171
+ i = 0
172
+ while i < len(value):
173
+ char = value[i]
174
+ if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"):
175
+ if not in_quotes:
176
+ in_quotes = True
177
+ quote_char = char
178
+ elif char == quote_char:
179
+ in_quotes = False
180
+ quote_char = None
181
+ else:
182
+ current_part += char
183
+ elif char == " " and not in_quotes:
184
+ if current_part:
185
+ parts.append(current_part)
186
+ current_part = ""
187
+ else:
188
+ current_part += char
189
+ i += 1
190
+
191
+ if current_part:
192
+ parts.append(current_part)
193
+
194
+ if len(parts) >= 2:
195
+ src = parts[0]
196
+ dest = parts[-1] # Last part is destination
197
+ template_builder.copy(src, dest)
198
+
199
+
200
+ def _handle_workdir_instruction(
201
+ value: str, template_builder: DockerfileParserInterface
202
+ ) -> None:
203
+ """Handle WORKDIR instruction"""
204
+ if not value.strip():
205
+ return
206
+ workdir = value.strip()
207
+ template_builder.set_workdir(workdir)
208
+
209
+
210
+ def _handle_user_instruction(
211
+ value: str, template_builder: DockerfileParserInterface
212
+ ) -> None:
213
+ """Handle USER instruction"""
214
+ if not value.strip():
215
+ return
216
+ user = value.strip()
217
+ template_builder.set_user(user)
218
+
219
+
220
+ def _handle_env_instruction(
221
+ value: str, instruction_type: str, template_builder: DockerfileParserInterface
222
+ ) -> None:
223
+ """Handle ENV/ARG instruction"""
224
+ if not value.strip():
225
+ return
226
+
227
+ # Parse environment variables from the value
228
+ # Handle both "KEY=value" and "KEY value" formats
229
+ env_vars = {}
230
+
231
+ # First try to split on = for KEY=value format
232
+ if "=" in value:
233
+ # Handle multiple KEY=value pairs on one line
234
+ pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value)
235
+ for key, val in pairs:
236
+ env_vars[key] = val.strip("\"'")
237
+ else:
238
+ # Handle "KEY value" format
239
+ parts = value.split(None, 1)
240
+ if len(parts) == 2:
241
+ key, val = parts
242
+ env_vars[key] = val.strip("\"'")
243
+ elif len(parts) == 1 and instruction_type == "ARG":
244
+ # ARG without default value
245
+ key = parts[0]
246
+ env_vars[key] = ""
247
+
248
+ # Add each environment variable
249
+ if env_vars:
250
+ template_builder.set_envs(env_vars)
251
+
252
+
253
+ def _handle_cmd_entrypoint_instruction(
254
+ value: str, template_builder: DockerfileParserInterface
255
+ ) -> None:
256
+ """Handle CMD/ENTRYPOINT instruction - convert to set_start_cmd with 20s timeout"""
257
+ if not value.strip():
258
+ return
259
+ command = value.strip()
260
+
261
+ # Try to parse as JSON (for array format like CMD ["sleep", "infinity"])
262
+ try:
263
+ parsed_command = json.loads(command)
264
+ if isinstance(parsed_command, list):
265
+ command = " ".join(str(item) for item in parsed_command)
266
+ except Exception:
267
+ pass
268
+
269
+ # Import wait_for_timeout locally to avoid circular dependency
270
+ def wait_for_timeout(timeout: int) -> str:
271
+ # convert to seconds, but ensure minimum of 1 second
272
+ seconds = max(1, timeout // 1000)
273
+ return f"sleep {seconds}"
274
+
275
+ template_builder.set_start_cmd(command, wait_for_timeout(20_000))