opensandbox-cli 0.1.0.dev1__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.
@@ -0,0 +1,99 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Experimental DevOps diagnostics commands: logs, inspect, events, summary."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import click
20
+
21
+ from opensandbox_cli.client import ClientContext
22
+ from opensandbox_cli.utils import handle_errors
23
+
24
+
25
+ def _fetch_plain_text(obj: ClientContext, sandbox_id: str, endpoint: str, params: dict | None = None) -> str:
26
+ """Fetch a diagnostics endpoint and return the plain-text body."""
27
+ sandbox_id = obj.resolve_sandbox_id(sandbox_id)
28
+ client = obj.get_devops_client()
29
+ resp = client.get(f"/sandboxes/{sandbox_id}/diagnostics/{endpoint}", params=params)
30
+ if resp.status_code == 404:
31
+ raise click.ClickException(f"Sandbox '{sandbox_id}' not found.")
32
+ resp.raise_for_status()
33
+ return resp.text
34
+
35
+
36
+ @click.group("devops", invoke_without_command=True)
37
+ @click.pass_context
38
+ def devops_group(ctx: click.Context) -> None:
39
+ """Experimental diagnostics for sandbox troubleshooting."""
40
+ if ctx.invoked_subcommand is None:
41
+ click.echo(ctx.get_help())
42
+
43
+
44
+ # ---- logs ----------------------------------------------------------------
45
+
46
+ @devops_group.command("logs")
47
+ @click.argument("sandbox_id")
48
+ @click.option("--tail", "-n", type=int, default=100, show_default=True, help="Number of trailing log lines.")
49
+ @click.option("--since", "-s", default=None, help="Only logs newer than this duration (e.g. 10m, 1h).")
50
+ @click.pass_obj
51
+ @handle_errors
52
+ def devops_logs(obj: ClientContext, sandbox_id: str, tail: int, since: str | None) -> None:
53
+ """Retrieve container logs for a sandbox."""
54
+ params: dict = {"tail": tail}
55
+ if since:
56
+ params["since"] = since
57
+ text = _fetch_plain_text(obj, sandbox_id, "logs", params=params)
58
+ click.echo(text)
59
+
60
+
61
+ # ---- inspect -------------------------------------------------------------
62
+
63
+ @devops_group.command("inspect")
64
+ @click.argument("sandbox_id")
65
+ @click.pass_obj
66
+ @handle_errors
67
+ def devops_inspect(obj: ClientContext, sandbox_id: str) -> None:
68
+ """Retrieve detailed container/pod inspection info."""
69
+ text = _fetch_plain_text(obj, sandbox_id, "inspect")
70
+ click.echo(text)
71
+
72
+
73
+ # ---- events --------------------------------------------------------------
74
+
75
+ @devops_group.command("events")
76
+ @click.argument("sandbox_id")
77
+ @click.option("--limit", "-l", type=int, default=50, show_default=True, help="Maximum number of events.")
78
+ @click.pass_obj
79
+ @handle_errors
80
+ def devops_events(obj: ClientContext, sandbox_id: str, limit: int) -> None:
81
+ """Retrieve events related to a sandbox."""
82
+ params: dict = {"limit": limit}
83
+ text = _fetch_plain_text(obj, sandbox_id, "events", params=params)
84
+ click.echo(text)
85
+
86
+
87
+ # ---- summary -------------------------------------------------------------
88
+
89
+ @devops_group.command("summary")
90
+ @click.argument("sandbox_id")
91
+ @click.option("--tail", "-n", type=int, default=50, show_default=True, help="Number of trailing log lines.")
92
+ @click.option("--event-limit", type=int, default=20, show_default=True, help="Maximum number of events.")
93
+ @click.pass_obj
94
+ @handle_errors
95
+ def devops_summary(obj: ClientContext, sandbox_id: str, tail: int, event_limit: int) -> None:
96
+ """One-shot diagnostics: inspect + events + logs combined."""
97
+ params: dict = {"tail": tail, "event_limit": event_limit}
98
+ text = _fetch_plain_text(obj, sandbox_id, "summary", params=params)
99
+ click.echo(text)
@@ -0,0 +1,89 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Runtime egress policy commands."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import click
20
+ from opensandbox.models.sandboxes import NetworkRule
21
+
22
+ from opensandbox_cli.client import ClientContext
23
+ from opensandbox_cli.utils import handle_errors
24
+
25
+
26
+ def _parse_rule(value: str) -> NetworkRule:
27
+ """Parse ACTION=TARGET into a NetworkRule."""
28
+ action, sep, target = value.partition("=")
29
+ if not sep:
30
+ raise click.BadParameter(
31
+ f"Invalid rule '{value}'. Use ACTION=TARGET, for example allow=pypi.org."
32
+ )
33
+ action = action.strip().lower()
34
+ target = target.strip()
35
+ if action not in ("allow", "deny"):
36
+ raise click.BadParameter(
37
+ f"Invalid rule action '{action}'. Use allow or deny."
38
+ )
39
+ return NetworkRule(action=action, target=target)
40
+
41
+
42
+ @click.group("egress", invoke_without_command=True)
43
+ @click.pass_context
44
+ def egress_group(ctx: click.Context) -> None:
45
+ """Manage runtime egress policy for a sandbox."""
46
+ if ctx.invoked_subcommand is None:
47
+ click.echo(ctx.get_help())
48
+
49
+
50
+ @egress_group.command("get")
51
+ @click.argument("sandbox_id")
52
+ @click.pass_obj
53
+ @handle_errors
54
+ def egress_get(obj: ClientContext, sandbox_id: str) -> None:
55
+ """Get the current egress policy."""
56
+ sandbox = obj.connect_sandbox(sandbox_id)
57
+ try:
58
+ policy = sandbox.get_egress_policy()
59
+ obj.output.print_model(policy, title="Egress Policy")
60
+ finally:
61
+ sandbox.close()
62
+
63
+
64
+ @egress_group.command("patch")
65
+ @click.argument("sandbox_id")
66
+ @click.option(
67
+ "--rule",
68
+ "rules",
69
+ multiple=True,
70
+ required=True,
71
+ help="Patch rule in ACTION=TARGET form. Repeatable, e.g. --rule allow=pypi.org.",
72
+ )
73
+ @click.pass_obj
74
+ @handle_errors
75
+ def egress_patch(obj: ClientContext, sandbox_id: str, rules: tuple[str, ...]) -> None:
76
+ """Patch runtime egress rules with merge semantics."""
77
+ parsed_rules = [_parse_rule(rule) for rule in rules]
78
+ sandbox = obj.connect_sandbox(sandbox_id)
79
+ try:
80
+ sandbox.patch_egress_rules(parsed_rules)
81
+ obj.output.success_panel(
82
+ {
83
+ "sandbox_id": sandbox.id,
84
+ "patched_rules": [rule.model_dump(mode="json") for rule in parsed_rules],
85
+ },
86
+ title="Egress Policy Patched",
87
+ )
88
+ finally:
89
+ sandbox.close()
@@ -0,0 +1,360 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """File operation commands: cat, write, upload, download, rm, mv, mkdir, rmdir, search, info, chmod, replace."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import click
23
+
24
+ from opensandbox_cli.client import ClientContext
25
+ from opensandbox_cli.utils import handle_errors
26
+
27
+
28
+ def _parse_permission_mode(mode: str) -> int:
29
+ """Parse a permission mode string into a base-10 integer."""
30
+ try:
31
+ return int(mode)
32
+ except ValueError as exc:
33
+ raise click.BadParameter(
34
+ f"Invalid permission mode '{mode}'. Use a base-10 integer like 644 or 755."
35
+ ) from exc
36
+
37
+
38
+ @click.group("file", invoke_without_command=True)
39
+ @click.pass_context
40
+ def file_group(ctx: click.Context) -> None:
41
+ """📁 File operations on a sandbox."""
42
+ if ctx.invoked_subcommand is None:
43
+ click.echo(ctx.get_help())
44
+
45
+
46
+ # ---- cat (read) -----------------------------------------------------------
47
+
48
+ @file_group.command("cat")
49
+ @click.argument("sandbox_id")
50
+ @click.argument("path")
51
+ @click.option("--encoding", default="utf-8", help="File encoding.")
52
+ @click.pass_obj
53
+ @handle_errors
54
+ def file_cat(obj: ClientContext, sandbox_id: str, path: str, encoding: str) -> None:
55
+ """Read a file from the sandbox."""
56
+ sandbox = obj.connect_sandbox(sandbox_id)
57
+ try:
58
+ content = sandbox.files.read_file(path, encoding=encoding)
59
+ click.echo(content, nl=False)
60
+ finally:
61
+ sandbox.close()
62
+
63
+
64
+ # ---- write ----------------------------------------------------------------
65
+
66
+ @file_group.command("write")
67
+ @click.argument("sandbox_id")
68
+ @click.argument("path")
69
+ @click.option("--content", "-c", default=None, help="Content to write. Reads from stdin if not provided.")
70
+ @click.option("--encoding", default="utf-8", help="File encoding.")
71
+ @click.option("--mode", default=None, help="File permission mode (e.g. 0644).")
72
+ @click.option("--owner", default=None, help="File owner.")
73
+ @click.option("--group", default=None, help="File group.")
74
+ @click.pass_obj
75
+ @handle_errors
76
+ def file_write(
77
+ obj: ClientContext,
78
+ sandbox_id: str,
79
+ path: str,
80
+ content: str | None,
81
+ encoding: str,
82
+ mode: str | None,
83
+ owner: str | None,
84
+ group: str | None,
85
+ ) -> None:
86
+ """Write content to a file in the sandbox."""
87
+ file_content = content
88
+ if file_content is None:
89
+ if sys.stdin.isatty():
90
+ click.echo("Reading from stdin (Ctrl+D to finish):", err=True)
91
+ file_content = sys.stdin.read()
92
+
93
+ sandbox = obj.connect_sandbox(sandbox_id)
94
+ try:
95
+ kwargs: dict = {"encoding": encoding}
96
+ if mode is not None:
97
+ kwargs["mode"] = _parse_permission_mode(mode)
98
+ if owner is not None:
99
+ kwargs["owner"] = owner
100
+ if group is not None:
101
+ kwargs["group"] = group
102
+ sandbox.files.write_file(path, file_content, **kwargs)
103
+ obj.output.success(f"Written: {path}")
104
+ finally:
105
+ sandbox.close()
106
+
107
+
108
+ # ---- upload ---------------------------------------------------------------
109
+
110
+ @file_group.command("upload")
111
+ @click.argument("sandbox_id")
112
+ @click.argument("local_path", type=click.Path(exists=True))
113
+ @click.argument("remote_path")
114
+ @click.pass_obj
115
+ @handle_errors
116
+ def file_upload(
117
+ obj: ClientContext, sandbox_id: str, local_path: str, remote_path: str
118
+ ) -> None:
119
+ """Upload a local file to the sandbox."""
120
+ data = Path(local_path).read_bytes()
121
+ sandbox = obj.connect_sandbox(sandbox_id)
122
+ try:
123
+ sandbox.files.write_file(remote_path, data)
124
+ obj.output.success(f"Uploaded: {local_path} → {remote_path}")
125
+ finally:
126
+ sandbox.close()
127
+
128
+
129
+ # ---- download -------------------------------------------------------------
130
+
131
+ @file_group.command("download")
132
+ @click.argument("sandbox_id")
133
+ @click.argument("remote_path")
134
+ @click.argument("local_path", type=click.Path())
135
+ @click.pass_obj
136
+ @handle_errors
137
+ def file_download(
138
+ obj: ClientContext, sandbox_id: str, remote_path: str, local_path: str
139
+ ) -> None:
140
+ """Download a file from the sandbox to local disk."""
141
+ sandbox = obj.connect_sandbox(sandbox_id)
142
+ try:
143
+ content = sandbox.files.read_bytes(remote_path)
144
+ Path(local_path).write_bytes(content)
145
+ obj.output.success(f"Downloaded: {remote_path} → {local_path}")
146
+ finally:
147
+ sandbox.close()
148
+
149
+
150
+ # ---- rm (delete) ----------------------------------------------------------
151
+
152
+ @file_group.command("rm")
153
+ @click.argument("sandbox_id")
154
+ @click.argument("paths", nargs=-1, required=True)
155
+ @click.pass_obj
156
+ @handle_errors
157
+ def file_rm(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:
158
+ """Delete files from the sandbox."""
159
+ sandbox = obj.connect_sandbox(sandbox_id)
160
+ try:
161
+ sandbox.files.delete_files(list(paths))
162
+ for p in paths:
163
+ obj.output.success(f"Deleted: {p}")
164
+ finally:
165
+ sandbox.close()
166
+
167
+
168
+ # ---- mv (move) ------------------------------------------------------------
169
+
170
+ @file_group.command("mv")
171
+ @click.argument("sandbox_id")
172
+ @click.argument("source")
173
+ @click.argument("destination")
174
+ @click.pass_obj
175
+ @handle_errors
176
+ def file_mv(
177
+ obj: ClientContext, sandbox_id: str, source: str, destination: str
178
+ ) -> None:
179
+ """Move/rename a file in the sandbox."""
180
+ from opensandbox.models.filesystem import MoveEntry
181
+
182
+ sandbox = obj.connect_sandbox(sandbox_id)
183
+ try:
184
+ sandbox.files.move_files([MoveEntry(source=source, destination=destination)])
185
+ obj.output.success(f"Moved: {source} → {destination}")
186
+ finally:
187
+ sandbox.close()
188
+
189
+
190
+ # ---- mkdir ----------------------------------------------------------------
191
+
192
+ @file_group.command("mkdir")
193
+ @click.argument("sandbox_id")
194
+ @click.argument("paths", nargs=-1, required=True)
195
+ @click.option("--mode", default=None, help="Directory permission mode.")
196
+ @click.option("--owner", default=None, help="Directory owner.")
197
+ @click.option("--group", default=None, help="Directory group.")
198
+ @click.pass_obj
199
+ @handle_errors
200
+ def file_mkdir(
201
+ obj: ClientContext,
202
+ sandbox_id: str,
203
+ paths: tuple[str, ...],
204
+ mode: str | None,
205
+ owner: str | None,
206
+ group: str | None,
207
+ ) -> None:
208
+ """Create directories in the sandbox."""
209
+ from opensandbox.models.filesystem import WriteEntry
210
+
211
+ sandbox = obj.connect_sandbox(sandbox_id)
212
+ try:
213
+ entries = []
214
+ for p in paths:
215
+ kwargs: dict = {"path": p}
216
+ if mode is not None:
217
+ kwargs["mode"] = _parse_permission_mode(mode)
218
+ if owner is not None:
219
+ kwargs["owner"] = owner
220
+ if group is not None:
221
+ kwargs["group"] = group
222
+ entries.append(WriteEntry(**kwargs))
223
+ sandbox.files.create_directories(entries)
224
+ for p in paths:
225
+ obj.output.success(f"Created: {p}")
226
+ finally:
227
+ sandbox.close()
228
+
229
+
230
+ # ---- rmdir ----------------------------------------------------------------
231
+
232
+ @file_group.command("rmdir")
233
+ @click.argument("sandbox_id")
234
+ @click.argument("paths", nargs=-1, required=True)
235
+ @click.pass_obj
236
+ @handle_errors
237
+ def file_rmdir(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:
238
+ """Delete directories from the sandbox."""
239
+ sandbox = obj.connect_sandbox(sandbox_id)
240
+ try:
241
+ sandbox.files.delete_directories(list(paths))
242
+ for p in paths:
243
+ obj.output.success(f"Removed: {p}")
244
+ finally:
245
+ sandbox.close()
246
+
247
+
248
+ # ---- search ---------------------------------------------------------------
249
+
250
+ @file_group.command("search")
251
+ @click.argument("sandbox_id")
252
+ @click.argument("path")
253
+ @click.option("--pattern", "-p", required=True, help="Glob pattern to search for.")
254
+ @click.pass_obj
255
+ @handle_errors
256
+ def file_search(
257
+ obj: ClientContext, sandbox_id: str, path: str, pattern: str
258
+ ) -> None:
259
+ """Search for files in the sandbox."""
260
+ from opensandbox.models.filesystem import SearchEntry
261
+
262
+ sandbox = obj.connect_sandbox(sandbox_id)
263
+ try:
264
+ results = sandbox.files.search(SearchEntry(path=path, pattern=pattern))
265
+ if not results:
266
+ if obj.output.fmt in ("json", "yaml"):
267
+ obj.output.print_models([], columns=[])
268
+ else:
269
+ obj.output.info("No files found.")
270
+ return
271
+ if obj.output.fmt in ("json", "yaml"):
272
+ obj.output.print_models(results, columns=["path", "size", "mode", "owner", "modified_at"])
273
+ else:
274
+ obj.output.print_models(results, columns=["path", "size", "owner"], title="Search Results")
275
+ finally:
276
+ sandbox.close()
277
+
278
+
279
+ # ---- info (stat) ----------------------------------------------------------
280
+
281
+ @file_group.command("info")
282
+ @click.argument("sandbox_id")
283
+ @click.argument("paths", nargs=-1, required=True)
284
+ @click.pass_obj
285
+ @handle_errors
286
+ def file_info(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:
287
+ """Get file/directory info."""
288
+ sandbox = obj.connect_sandbox(sandbox_id)
289
+ try:
290
+ info_map = sandbox.files.get_file_info(list(paths))
291
+ for path, entry in info_map.items():
292
+ obj.output.print_dict(
293
+ {"path": path, **entry.model_dump(mode="json")},
294
+ title=path,
295
+ )
296
+ finally:
297
+ sandbox.close()
298
+
299
+
300
+ # ---- chmod ----------------------------------------------------------------
301
+
302
+ @file_group.command("chmod")
303
+ @click.argument("sandbox_id")
304
+ @click.argument("path")
305
+ @click.option("--mode", required=True, help="Permission mode (e.g. 0755).")
306
+ @click.option("--owner", default=None, help="File owner.")
307
+ @click.option("--group", default=None, help="File group.")
308
+ @click.pass_obj
309
+ @handle_errors
310
+ def file_chmod(
311
+ obj: ClientContext,
312
+ sandbox_id: str,
313
+ path: str,
314
+ mode: str,
315
+ owner: str | None,
316
+ group: str | None,
317
+ ) -> None:
318
+ """Set file permissions."""
319
+ from opensandbox.models.filesystem import SetPermissionEntry
320
+
321
+ sandbox = obj.connect_sandbox(sandbox_id)
322
+ try:
323
+ sandbox.files.set_permissions(
324
+ [
325
+ SetPermissionEntry(
326
+ path=path,
327
+ mode=_parse_permission_mode(mode),
328
+ owner=owner,
329
+ group=group,
330
+ )
331
+ ]
332
+ )
333
+ obj.output.success(f"Permissions set: {path}")
334
+ finally:
335
+ sandbox.close()
336
+
337
+
338
+ # ---- replace --------------------------------------------------------------
339
+
340
+ @file_group.command("replace")
341
+ @click.argument("sandbox_id")
342
+ @click.argument("path")
343
+ @click.option("--old", required=True, help="Text to search for.")
344
+ @click.option("--new", required=True, help="Replacement text.")
345
+ @click.pass_obj
346
+ @handle_errors
347
+ def file_replace(
348
+ obj: ClientContext, sandbox_id: str, path: str, old: str, new: str
349
+ ) -> None:
350
+ """Replace content in a file."""
351
+ from opensandbox.models.filesystem import ContentReplaceEntry
352
+
353
+ sandbox = obj.connect_sandbox(sandbox_id)
354
+ try:
355
+ sandbox.files.replace_contents(
356
+ [ContentReplaceEntry(path=path, old_content=old, new_content=new)]
357
+ )
358
+ obj.output.success(f"Replaced in: {path}")
359
+ finally:
360
+ sandbox.close()