opensandbox-cli 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.
- opensandbox_cli/__init__.py +20 -0
- opensandbox_cli/__main__.py +20 -0
- opensandbox_cli/client.py +127 -0
- opensandbox_cli/commands/__init__.py +13 -0
- opensandbox_cli/commands/command.py +359 -0
- opensandbox_cli/commands/config_cmd.py +183 -0
- opensandbox_cli/commands/devops.py +123 -0
- opensandbox_cli/commands/egress.py +98 -0
- opensandbox_cli/commands/file.py +442 -0
- opensandbox_cli/commands/sandbox.py +580 -0
- opensandbox_cli/commands/skills.py +775 -0
- opensandbox_cli/config.py +160 -0
- opensandbox_cli/main.py +138 -0
- opensandbox_cli/output.py +363 -0
- opensandbox_cli/py.typed +1 -0
- opensandbox_cli/skill_registry.py +184 -0
- opensandbox_cli/skills/opensandbox-command-execution.md +215 -0
- opensandbox_cli/skills/opensandbox-file-operations.md +244 -0
- opensandbox_cli/skills/opensandbox-network-egress.md +179 -0
- opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +305 -0
- opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md +177 -0
- opensandbox_cli/utils.py +212 -0
- opensandbox_cli-0.1.0.dist-info/METADATA +597 -0
- opensandbox_cli-0.1.0.dist-info/RECORD +27 -0
- opensandbox_cli-0.1.0.dist-info/WHEEL +4 -0
- opensandbox_cli-0.1.0.dist-info/entry_points.txt +3 -0
- opensandbox_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
"""Config management commands: init, show, set."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
from opensandbox_cli.client import ClientContext
|
|
25
|
+
from opensandbox_cli.config import init_config_file, resolve_config
|
|
26
|
+
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group("config", invoke_without_command=True)
|
|
30
|
+
@click.pass_context
|
|
31
|
+
def config_group(ctx: click.Context) -> None:
|
|
32
|
+
"""⚙️ Manage CLI configuration."""
|
|
33
|
+
if ctx.invoked_subcommand is None:
|
|
34
|
+
click.echo(ctx.get_help())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---- init -----------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@config_group.command("init")
|
|
40
|
+
@click.option("--force", is_flag=True, default=False, help="Overwrite existing config file.")
|
|
41
|
+
@output_option("table", "json", "yaml")
|
|
42
|
+
@handle_errors
|
|
43
|
+
@click.pass_obj
|
|
44
|
+
def config_init(obj: ClientContext, force: bool, output_format: str | None) -> None:
|
|
45
|
+
"""Create a default configuration file."""
|
|
46
|
+
output = prepare_output(
|
|
47
|
+
obj, output_format, allowed=("table", "json", "yaml"), fallback="table"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
path = init_config_file(obj.config_path, force=force)
|
|
52
|
+
output.success(f"Config file created: {path}")
|
|
53
|
+
except FileExistsError as exc:
|
|
54
|
+
output.warning(str(exc))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---- show -----------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_SENSITIVE_CONFIG_KEYS = {
|
|
60
|
+
"api_key",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _mask_secret(value: str | None) -> str | None:
|
|
65
|
+
"""Mask a secret while keeping enough shape for debugging."""
|
|
66
|
+
if value is None:
|
|
67
|
+
return None
|
|
68
|
+
if len(value) <= 4:
|
|
69
|
+
return "*" * len(value)
|
|
70
|
+
return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _sanitize_config_for_display(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
74
|
+
"""Return a display-safe copy of the resolved config."""
|
|
75
|
+
sanitized: dict[str, Any] = {}
|
|
76
|
+
for key, value in data.items():
|
|
77
|
+
if key in _SENSITIVE_CONFIG_KEYS:
|
|
78
|
+
sanitized[key] = _mask_secret(value if isinstance(value, str) else None)
|
|
79
|
+
continue
|
|
80
|
+
sanitized[key] = value
|
|
81
|
+
return sanitized
|
|
82
|
+
|
|
83
|
+
@config_group.command("show")
|
|
84
|
+
@output_option("table", "json", "yaml")
|
|
85
|
+
@click.pass_obj
|
|
86
|
+
@handle_errors
|
|
87
|
+
def config_show(obj: ClientContext, output_format: str | None) -> None:
|
|
88
|
+
"""Show the resolved configuration."""
|
|
89
|
+
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
|
|
90
|
+
resolved = resolve_config(
|
|
91
|
+
cli_api_key=obj.cli_overrides.get("api_key"),
|
|
92
|
+
cli_domain=obj.cli_overrides.get("domain"),
|
|
93
|
+
cli_protocol=obj.cli_overrides.get("protocol"),
|
|
94
|
+
cli_timeout=obj.cli_overrides.get("request_timeout"),
|
|
95
|
+
cli_use_server_proxy=obj.cli_overrides.get("use_server_proxy"),
|
|
96
|
+
config_path=obj.config_path,
|
|
97
|
+
)
|
|
98
|
+
obj.output.print_dict(
|
|
99
|
+
{
|
|
100
|
+
**_sanitize_config_for_display(resolved),
|
|
101
|
+
"config_path": str(obj.config_path),
|
|
102
|
+
"config_file_exists": obj.config_path.exists(),
|
|
103
|
+
},
|
|
104
|
+
title="Resolved Configuration",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- set ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
@config_group.command("set")
|
|
111
|
+
@click.argument("key")
|
|
112
|
+
@click.argument("value")
|
|
113
|
+
@output_option("table", "json", "yaml")
|
|
114
|
+
@handle_errors
|
|
115
|
+
@click.pass_obj
|
|
116
|
+
def config_set(
|
|
117
|
+
obj: ClientContext,
|
|
118
|
+
key: str,
|
|
119
|
+
value: str,
|
|
120
|
+
output_format: str | None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Set a configuration value (e.g. 'connection.domain' 'localhost:9090')."""
|
|
123
|
+
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
|
|
124
|
+
path = obj.config_path
|
|
125
|
+
if not path.exists():
|
|
126
|
+
raise click.ClickException(f"Config file not found: {path}. Run 'osb config init' first.")
|
|
127
|
+
|
|
128
|
+
content = path.read_text()
|
|
129
|
+
|
|
130
|
+
# TODO: Replace this regex-based TOML editing with a parser-backed update
|
|
131
|
+
# path so formatting/comments survive reliably as config complexity grows.
|
|
132
|
+
# Simple key replacement in TOML
|
|
133
|
+
# Supports dotted keys like connection.domain
|
|
134
|
+
parts = key.split(".", 1)
|
|
135
|
+
if len(parts) == 2:
|
|
136
|
+
section, field = parts
|
|
137
|
+
# Try to find and update existing value
|
|
138
|
+
import re
|
|
139
|
+
|
|
140
|
+
section_pattern = rf"(\[{re.escape(section)}\].*?)(?=\n\[|\Z)"
|
|
141
|
+
section_match = re.search(section_pattern, content, re.DOTALL)
|
|
142
|
+
|
|
143
|
+
# Infer TOML value type: bool > int > float > string
|
|
144
|
+
def _toml_value(raw: str) -> str:
|
|
145
|
+
if raw.lower() in ("true", "false"):
|
|
146
|
+
return raw.lower()
|
|
147
|
+
try:
|
|
148
|
+
int(raw)
|
|
149
|
+
return raw
|
|
150
|
+
except ValueError:
|
|
151
|
+
pass
|
|
152
|
+
try:
|
|
153
|
+
float(raw)
|
|
154
|
+
return raw
|
|
155
|
+
except ValueError:
|
|
156
|
+
pass
|
|
157
|
+
return f'"{raw}"'
|
|
158
|
+
|
|
159
|
+
toml_val = _toml_value(value)
|
|
160
|
+
|
|
161
|
+
if section_match:
|
|
162
|
+
section_text = section_match.group(1)
|
|
163
|
+
field_pattern = rf'^(#?\s*{re.escape(field)}\s*=\s*).*$'
|
|
164
|
+
field_match = re.search(field_pattern, section_text, re.MULTILINE)
|
|
165
|
+
if field_match:
|
|
166
|
+
new_line = f'{field} = {toml_val}'
|
|
167
|
+
new_section = section_text[:field_match.start()] + new_line + section_text[field_match.end():]
|
|
168
|
+
content = content[:section_match.start()] + new_section + content[section_match.end():]
|
|
169
|
+
else:
|
|
170
|
+
# Add field to section
|
|
171
|
+
insert_pos = section_match.end()
|
|
172
|
+
content = content[:insert_pos] + f'\n{field} = {toml_val}' + content[insert_pos:]
|
|
173
|
+
else:
|
|
174
|
+
# Add new section
|
|
175
|
+
content += f'\n[{section}]\n{field} = {toml_val}\n'
|
|
176
|
+
else:
|
|
177
|
+
raise click.ClickException(
|
|
178
|
+
"Key must be in 'section.field' format (e.g. connection.domain)."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
path.write_text(content)
|
|
182
|
+
|
|
183
|
+
obj.output.success(f"Set {key} = {value}")
|
|
@@ -0,0 +1,123 @@
|
|
|
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, output_option, prepare_output
|
|
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
|
+
@output_option("raw", help_text="Output format: raw.")
|
|
51
|
+
@click.pass_obj
|
|
52
|
+
@handle_errors
|
|
53
|
+
def devops_logs(
|
|
54
|
+
obj: ClientContext,
|
|
55
|
+
sandbox_id: str,
|
|
56
|
+
tail: int,
|
|
57
|
+
since: str | None,
|
|
58
|
+
output_format: str | None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Retrieve container logs for a sandbox."""
|
|
61
|
+
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
|
|
62
|
+
params: dict = {"tail": tail}
|
|
63
|
+
if since:
|
|
64
|
+
params["since"] = since
|
|
65
|
+
text = _fetch_plain_text(obj, sandbox_id, "logs", params=params)
|
|
66
|
+
click.echo(text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---- inspect -------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@devops_group.command("inspect")
|
|
72
|
+
@click.argument("sandbox_id")
|
|
73
|
+
@output_option("raw", help_text="Output format: raw.")
|
|
74
|
+
@click.pass_obj
|
|
75
|
+
@handle_errors
|
|
76
|
+
def devops_inspect(
|
|
77
|
+
obj: ClientContext, sandbox_id: str, output_format: str | None
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Retrieve detailed container/pod inspection info."""
|
|
80
|
+
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
|
|
81
|
+
text = _fetch_plain_text(obj, sandbox_id, "inspect")
|
|
82
|
+
click.echo(text)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---- events --------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@devops_group.command("events")
|
|
88
|
+
@click.argument("sandbox_id")
|
|
89
|
+
@click.option("--limit", "-l", type=int, default=50, show_default=True, help="Maximum number of events.")
|
|
90
|
+
@output_option("raw", help_text="Output format: raw.")
|
|
91
|
+
@click.pass_obj
|
|
92
|
+
@handle_errors
|
|
93
|
+
def devops_events(
|
|
94
|
+
obj: ClientContext, sandbox_id: str, limit: int, output_format: str | None
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Retrieve events related to a sandbox."""
|
|
97
|
+
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
|
|
98
|
+
params: dict = {"limit": limit}
|
|
99
|
+
text = _fetch_plain_text(obj, sandbox_id, "events", params=params)
|
|
100
|
+
click.echo(text)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---- summary -------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@devops_group.command("summary")
|
|
106
|
+
@click.argument("sandbox_id")
|
|
107
|
+
@click.option("--tail", "-n", type=int, default=50, show_default=True, help="Number of trailing log lines.")
|
|
108
|
+
@click.option("--event-limit", type=int, default=20, show_default=True, help="Maximum number of events.")
|
|
109
|
+
@output_option("raw", help_text="Output format: raw.")
|
|
110
|
+
@click.pass_obj
|
|
111
|
+
@handle_errors
|
|
112
|
+
def devops_summary(
|
|
113
|
+
obj: ClientContext,
|
|
114
|
+
sandbox_id: str,
|
|
115
|
+
tail: int,
|
|
116
|
+
event_limit: int,
|
|
117
|
+
output_format: str | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""One-shot diagnostics: inspect + events + logs combined."""
|
|
120
|
+
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
|
|
121
|
+
params: dict = {"tail": tail, "event_limit": event_limit}
|
|
122
|
+
text = _fetch_plain_text(obj, sandbox_id, "summary", params=params)
|
|
123
|
+
click.echo(text)
|
|
@@ -0,0 +1,98 @@
|
|
|
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, output_option, prepare_output
|
|
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
|
+
@output_option("table", "json", "yaml")
|
|
53
|
+
@click.pass_obj
|
|
54
|
+
@handle_errors
|
|
55
|
+
def egress_get(obj: ClientContext, sandbox_id: str, output_format: str | None) -> None:
|
|
56
|
+
"""Get the current egress policy."""
|
|
57
|
+
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
|
|
58
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
59
|
+
try:
|
|
60
|
+
policy = sandbox.get_egress_policy()
|
|
61
|
+
obj.output.print_model(policy, title="Egress Policy")
|
|
62
|
+
finally:
|
|
63
|
+
sandbox.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@egress_group.command("patch")
|
|
67
|
+
@click.argument("sandbox_id")
|
|
68
|
+
@click.option(
|
|
69
|
+
"--rule",
|
|
70
|
+
"rules",
|
|
71
|
+
multiple=True,
|
|
72
|
+
required=True,
|
|
73
|
+
help="Patch rule in ACTION=TARGET form. Repeatable, e.g. --rule allow=pypi.org.",
|
|
74
|
+
)
|
|
75
|
+
@output_option("table", "json", "yaml")
|
|
76
|
+
@click.pass_obj
|
|
77
|
+
@handle_errors
|
|
78
|
+
def egress_patch(
|
|
79
|
+
obj: ClientContext,
|
|
80
|
+
sandbox_id: str,
|
|
81
|
+
rules: tuple[str, ...],
|
|
82
|
+
output_format: str | None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Patch runtime egress rules with merge semantics."""
|
|
85
|
+
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
|
|
86
|
+
parsed_rules = [_parse_rule(rule) for rule in rules]
|
|
87
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
88
|
+
try:
|
|
89
|
+
sandbox.patch_egress_rules(parsed_rules)
|
|
90
|
+
obj.output.success_panel(
|
|
91
|
+
{
|
|
92
|
+
"sandbox_id": sandbox.id,
|
|
93
|
+
"patched_rules": [rule.model_dump(mode="json") for rule in parsed_rules],
|
|
94
|
+
},
|
|
95
|
+
title="Egress Policy Patched",
|
|
96
|
+
)
|
|
97
|
+
finally:
|
|
98
|
+
sandbox.close()
|