Files
wehub-resource-sync e0e362d700
Deploy Docs Pages / build (push) Waiting to run
Deploy Docs Pages / deploy (push) Blocked by required conditions
Real E2E Tests / Real E2E CI (push) Blocked by required conditions
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Real E2E Tests / Python E2E (docker bridge) (push) Waiting to run
Real E2E Tests / C# E2E (docker bridge) (push) Waiting to run
Real E2E Tests / Go E2E (docker bridge) (push) Waiting to run
Real E2E Tests / Java E2E (docker bridge) (push) Waiting to run
Real E2E Tests / JavaScript E2E (docker bridge) (push) Waiting to run
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

314 lines
11 KiB
Python

# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import argparse
import os
import shutil
import types
from importlib import resources
from pathlib import Path
from typing import Any, FrozenSet, Union, get_args, get_origin
import uvicorn
from pydantic import BaseModel
from opensandbox_server.config import (
AgentSandboxRuntimeConfig,
CONFIG_ENV_VAR,
DEFAULT_CONFIG_PATH,
DockerConfig,
EgressConfig,
IngressConfig,
KubernetesRuntimeConfig,
RenewIntentConfig,
RuntimeConfig,
ServerConfig,
StorageConfig,
load_config,
)
from opensandbox_server.logging_config import configure_logging
def _strip_optional(annotation: Any) -> Any:
"""Unwrap Optional / Union[..., None] to the inner type."""
if annotation is None:
return None
origin = get_origin(annotation)
args = get_args(annotation)
if origin is Union or origin is types.UnionType:
filtered = [a for a in args if a is not type(None)]
if len(filtered) == 1:
return filtered[0]
return annotation
def _is_basemodel_type(annotation: Any) -> bool:
inner = _strip_optional(annotation)
return isinstance(inner, type) and issubclass(inner, BaseModel)
EXAMPLE_FILE_MAP = {
"docker": "example.config.toml",
"docker-zh": "example.config.zh.toml",
"k8s": "example.config.k8s.toml",
"k8s-zh": "example.config.k8s.zh.toml",
}
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run the OpenSandbox server.",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"--config",
help="Path to the server config TOML file (overrides SANDBOX_CONFIG_PATH).",
)
parser.add_argument(
"--reload",
action="store_true",
help="Enable auto-reload (development only).",
)
subparsers = parser.add_subparsers(dest="command")
init_parser = subparsers.add_parser(
"init-config",
help="Generate a config file from packaged examples or the schema skeleton.",
)
init_parser.add_argument(
"path",
nargs="?",
default=str(DEFAULT_CONFIG_PATH),
help="Destination path for the config file (default: ~/.sandbox.toml).",
)
init_parser.add_argument(
"--example",
choices=sorted(EXAMPLE_FILE_MAP),
help=(
"Packaged example to copy (docker, docker-zh, k8s, k8s-zh). "
"Omit to render the full skeleton with placeholders."
),
)
init_parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing file when generating config.",
)
parser.epilog = (
"Subcommands:\n"
" init-config [path] [--example {docker,docker-zh,k8s,k8s-zh}] [--force]\n"
" Generate a config file. Without --example it renders the full skeleton (placeholders only).\n"
" --example Copy a packaged example config.\n"
" --force Overwrite destination if it exists.\n"
)
return parser
def copy_example_config(
destination: str | Path | None = None, *, force: bool = False, kind: str = "default"
) -> Path:
"""Copy a packaged example config template to the target path."""
if kind not in EXAMPLE_FILE_MAP:
supported = ", ".join(EXAMPLE_FILE_MAP)
raise ValueError(f"Unsupported example kind '{kind}'. Choices: {supported}")
filename = EXAMPLE_FILE_MAP[kind]
dest_path = Path(destination or DEFAULT_CONFIG_PATH).expanduser()
dest_path.parent.mkdir(parents=True, exist_ok=True)
if dest_path.exists() and not force:
raise FileExistsError(f"Config file already exists at {dest_path}. Use --force to overwrite.")
example_resource = resources.files("opensandbox_server.examples").joinpath(filename)
if not example_resource.is_file():
raise FileNotFoundError(f"Missing packaged example config template: {filename}")
with resources.as_file(example_resource) as src_path:
shutil.copyfile(src_path, dest_path)
return dest_path
def render_full_config(destination: str | Path | None = None, *, force: bool = False) -> Path:
"""
Render the most complete config skeleton from config models with comments.
No defaults are prefilled; everything is emitted as placeholders so users
must explicitly set values. Field comments come from pydantic Field
descriptions to stay in sync with the schema.
"""
def _placeholder_for_field(field) -> str:
"""Return a placeholder TOML value that is intentionally empty."""
ann = field.annotation
if ann is not None:
origin = getattr(ann, "__origin__", None)
if ann is list or origin is list:
return "[]"
return '""' # string placeholder for scalars/bool/int; user must replace
def _render_section(
section: str,
model,
*,
placeholders: dict[str, str] | None = None,
extra_comments: list[str] | None = None,
dotted_nested: FrozenSet[str] | None = None,
) -> str:
lines: list[str] = []
if extra_comments:
lines.extend([f"# {c}" for c in extra_comments])
lines.append(f"[{section}]")
placeholders = placeholders or {}
dotted_nested = dotted_nested or frozenset()
for field_name, field in model.model_fields.items():
if _is_basemodel_type(field.annotation):
continue
key = field.alias or field_name
value = placeholders.get(key, _placeholder_for_field(field))
if field.description:
lines.append(f"# {field.description}")
lines.append(f"{key} = {value}")
lines.append("")
for field_name, field in model.model_fields.items():
if field_name not in dotted_nested or not _is_basemodel_type(field.annotation):
continue
inner = _strip_optional(field.annotation)
if not isinstance(inner, type) or not issubclass(inner, BaseModel):
continue
for sub_name, sub_field in inner.model_fields.items():
sub_key = f"{field_name}.{sub_name}"
value = placeholders.get(sub_key, _placeholder_for_field(sub_field))
if sub_field.description:
lines.append(f"# {sub_field.description}")
lines.append(f"{sub_key} = {value}")
lines.append("")
nested_blocks: list[str] = []
for field_name, field in model.model_fields.items():
if not _is_basemodel_type(field.annotation):
continue
if field_name in dotted_nested:
continue
inner = _strip_optional(field.annotation)
if not isinstance(inner, type) or not issubclass(inner, BaseModel):
continue
nested_path = f"{section}.{field_name}"
nested_blocks.append(
_render_section(nested_path, inner, placeholders=None, extra_comments=None)
)
if nested_blocks:
if lines and lines[-1] == "":
lines.pop()
lines.append("")
lines.extend(nested_blocks)
if lines and lines[-1] == "":
lines.pop()
return "\n".join(lines)
dest_path = Path(destination or DEFAULT_CONFIG_PATH).expanduser()
dest_path.parent.mkdir(parents=True, exist_ok=True)
if dest_path.exists() and not force:
raise FileExistsError(f"Config file already exists at {dest_path}. Use --force to overwrite.")
sections = [
"# Generated from OpenSandbox config schema. Remove sections you do not use.",
_render_section("server", ServerConfig),
_render_section(
"renew_intent",
RenewIntentConfig,
extra_comments=[
"Renew-intent: top-level section (not under [server]). "
"Redis options use dotted keys in this table (redis.enabled, redis.queue_key, …)."
],
dotted_nested=frozenset({"redis"}),
),
_render_section("runtime", RuntimeConfig),
_render_section("docker", DockerConfig),
_render_section(
"egress",
EgressConfig,
extra_comments=["Used when networkPolicy is provided. Requires docker.network_mode = \"bridge\"."],
),
_render_section(
"kubernetes",
KubernetesRuntimeConfig,
extra_comments=["Only used when runtime.type = \"kubernetes\""],
),
_render_section(
"agent_sandbox",
AgentSandboxRuntimeConfig,
extra_comments=["Requires kubernetes.workload_provider = \"agent-sandbox\""],
),
_render_section("ingress", IngressConfig),
_render_section("storage", StorageConfig),
]
content = "\n\n".join(sections) + "\n"
dest_path.write_text(content, encoding="utf-8")
return dest_path
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
if args.command == "init-config":
try:
if args.example:
dest = copy_example_config(args.path, force=args.force, kind=args.example)
print(f"Wrote example config ({args.example}) to {dest}\n")
else:
dest = render_full_config(args.path, force=args.force)
print(f"Wrote full config skeleton to {dest}\n")
except Exception as exc: # noqa: BLE001
print(f"Failed to write config template: {exc}\n")
raise SystemExit(1)
return
if args.config:
os.environ[CONFIG_ENV_VAR] = args.config
# Load config + logging without importing opensandbox_server.main: importing
# main eagerly constructs sandbox_service (restoring containers and starting
# expiration timers), which we defer to the actual worker process so the
# uvicorn reloader supervisor does not run them.
app_config = load_config()
log_config = configure_logging(app_config.log)
server_cfg = app_config.server
uvicorn.run(
"opensandbox_server.main:app",
host=server_cfg.host,
port=server_cfg.port,
reload=args.reload,
log_config=log_config,
timeout_keep_alive=server_cfg.timeout_keep_alive,
limit_concurrency=server_cfg.limit_concurrency,
backlog=server_cfg.backlog,
loop=server_cfg.loop,
http=server_cfg.http,
timeout_graceful_shutdown=server_cfg.timeout_graceful_shutdown,
)
if __name__ == "__main__":
main()