Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ cp example.config.k8s.toml ~/.sandbox.toml
cp example.batchsandbox-template.yaml ~/batchsandbox-template.yaml
```

**Generate configs via CLI**:

- Copy packaged example for quick e2e/demo (specify which one):
```bash
opensandbox-server init-config ~/.sandbox.toml --example docker # or docker-zh|k8s|k8s-zh
# add --force to overwrite existing file
```
- Render the full schema-driven skeleton (no defaults, just placeholders) by omitting --example:
```bash
opensandbox-server init-config ~/.sandbox.toml
# add --force to overwrite existing file
```

**[optional] Edit `~/.sandbox.toml`** for your environment:

**Option A: Docker runtime + host networking (default)**
Expand Down
13 changes: 13 additions & 0 deletions server/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ cp example.config.k8s.zh.toml ~/.sandbox.toml
cp example.batchsandbox-template.yaml ~/batchsandbox-template.yaml
```

**通过 CLI 生成配置**:

- 拷贝打包示例(用于快速 e2e/demo,需要指定示例):
```bash
opensandbox-server init-config ~/.sandbox.toml --example docker # 或 docker-zh|k8s|k8s-zh
# 已有文件需覆盖时加 --force
```
- 省略 `--example` 时生成“配置框架”(无默认值,只有占位符):
```bash
opensandbox-server init-config ~/.sandbox.toml
# 已有文件需覆盖时加 --force
```

**[可选] 编辑 `~/.sandbox.toml`** 适配您的环境:


Expand Down
167 changes: 166 additions & 1 deletion server/src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,34 @@

import argparse
import os
import shutil
from pathlib import Path

import uvicorn

from src.config import CONFIG_ENV_VAR
from src.config import (
AgentSandboxRuntimeConfig,
CONFIG_ENV_VAR,
DEFAULT_CONFIG_PATH,
DockerConfig,
KubernetesRuntimeConfig,
RouterConfig,
RuntimeConfig,
ServerConfig,
)

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",
Expand All @@ -35,13 +54,159 @@ def _build_parser() -> argparse.ArgumentParser:
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]
src_path = Path(__file__).resolve().parent.parent / filename
if not src_path.exists():
raise FileNotFoundError(f"Missing example config template at {src_path}")

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.")

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,
) -> str:
lines: list[str] = []
if extra_comments:
lines.extend([f"# {c}" for c in extra_comments])
lines.append(f"[{section}]")

placeholders = placeholders or {}

for field_name, field in model.model_fields.items():
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("")

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("runtime", RuntimeConfig),
_render_section("docker", DockerConfig),
_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(
"router",
RouterConfig,
placeholders={"domain": '""', "wildcard-domain": '""'},
extra_comments=["Set exactly one of domain or wildcard-domain."],
),
]

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

Expand Down
Loading