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.
@@ -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()