axio-tools-docker 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.
- axio_tools_docker/__init__.py +7 -0
- axio_tools_docker/config.py +46 -0
- axio_tools_docker/handler.py +73 -0
- axio_tools_docker/manager.py +158 -0
- axio_tools_docker/plugin.py +87 -0
- axio_tools_docker/settings.py +170 -0
- axio_tools_docker-0.1.0.dist-info/METADATA +8 -0
- axio_tools_docker-0.1.0.dist-info/RECORD +11 -0
- axio_tools_docker-0.1.0.dist-info/WHEEL +4 -0
- axio_tools_docker-0.1.0.dist-info/entry_points.txt +2 -0
- axio_tools_docker-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SandboxConfig: frozen dataclass for Docker sandbox parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class SandboxConfig:
|
|
10
|
+
"""Configuration for a Docker sandbox container."""
|
|
11
|
+
|
|
12
|
+
image: str = "python:latest"
|
|
13
|
+
memory: str = "256m"
|
|
14
|
+
cpus: str = "1.0"
|
|
15
|
+
network: bool = False
|
|
16
|
+
workdir: str = "/workspace"
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str]:
|
|
19
|
+
"""Serialize to flat string dict for config DB persistence.
|
|
20
|
+
|
|
21
|
+
Only non-default values are included.
|
|
22
|
+
"""
|
|
23
|
+
defaults = SandboxConfig()
|
|
24
|
+
result: dict[str, str] = {}
|
|
25
|
+
if self.image != defaults.image:
|
|
26
|
+
result["image"] = self.image
|
|
27
|
+
if self.memory != defaults.memory:
|
|
28
|
+
result["memory"] = self.memory
|
|
29
|
+
if self.cpus != defaults.cpus:
|
|
30
|
+
result["cpus"] = self.cpus
|
|
31
|
+
if self.network != defaults.network:
|
|
32
|
+
result["network"] = str(self.network)
|
|
33
|
+
if self.workdir != defaults.workdir:
|
|
34
|
+
result["workdir"] = self.workdir
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: dict[str, str]) -> SandboxConfig:
|
|
39
|
+
"""Deserialize from flat string dict."""
|
|
40
|
+
return cls(
|
|
41
|
+
image=data.get("image", "python:latest"),
|
|
42
|
+
memory=data.get("memory", "256m"),
|
|
43
|
+
cpus=data.get("cpus", "1.0"),
|
|
44
|
+
network=data["network"].lower() in ("true", "1", "yes") if "network" in data else False,
|
|
45
|
+
workdir=data.get("workdir", "/workspace"),
|
|
46
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Dynamic ToolHandler builders for Docker sandbox tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from axio.tool import ToolHandler
|
|
8
|
+
|
|
9
|
+
from .manager import SandboxManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_sandbox_exec(manager: SandboxManager) -> type[ToolHandler]:
|
|
13
|
+
"""Create a ToolHandler that executes commands in the sandbox."""
|
|
14
|
+
|
|
15
|
+
class SandboxExecHandler(ToolHandler):
|
|
16
|
+
"""Execute a shell command inside a Docker sandbox container.
|
|
17
|
+
Returns combined stdout/stderr. Non-zero exit codes are reported.
|
|
18
|
+
Use for running code, tests, or CLI tools in an isolated environment."""
|
|
19
|
+
|
|
20
|
+
command: str
|
|
21
|
+
timeout: int = 30
|
|
22
|
+
|
|
23
|
+
_manager: ClassVar[SandboxManager]
|
|
24
|
+
|
|
25
|
+
async def __call__(self) -> str:
|
|
26
|
+
if not self._manager.docker_available():
|
|
27
|
+
return "Error: Docker is not installed or not on PATH"
|
|
28
|
+
return await self._manager.exec(self.command, timeout=self.timeout)
|
|
29
|
+
|
|
30
|
+
SandboxExecHandler._manager = manager
|
|
31
|
+
return SandboxExecHandler
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_sandbox_write(manager: SandboxManager) -> type[ToolHandler]:
|
|
35
|
+
"""Create a ToolHandler that writes files in the sandbox."""
|
|
36
|
+
|
|
37
|
+
class SandboxWriteHandler(ToolHandler):
|
|
38
|
+
"""Write content to a file inside the Docker sandbox container.
|
|
39
|
+
Creates parent directories as needed. Use for placing source code,
|
|
40
|
+
config files, or test data in the sandbox."""
|
|
41
|
+
|
|
42
|
+
path: str
|
|
43
|
+
content: str
|
|
44
|
+
|
|
45
|
+
_manager: ClassVar[SandboxManager]
|
|
46
|
+
|
|
47
|
+
async def __call__(self) -> str:
|
|
48
|
+
if not self._manager.docker_available():
|
|
49
|
+
return "Error: Docker is not installed or not on PATH"
|
|
50
|
+
return await self._manager.write_file(self.path, self.content)
|
|
51
|
+
|
|
52
|
+
SandboxWriteHandler._manager = manager
|
|
53
|
+
return SandboxWriteHandler
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_sandbox_read(manager: SandboxManager) -> type[ToolHandler]:
|
|
57
|
+
"""Create a ToolHandler that reads files from the sandbox."""
|
|
58
|
+
|
|
59
|
+
class SandboxReadHandler(ToolHandler):
|
|
60
|
+
"""Read the contents of a file from the Docker sandbox container.
|
|
61
|
+
Returns the full file content as text."""
|
|
62
|
+
|
|
63
|
+
path: str
|
|
64
|
+
|
|
65
|
+
_manager: ClassVar[SandboxManager]
|
|
66
|
+
|
|
67
|
+
async def __call__(self) -> str:
|
|
68
|
+
if not self._manager.docker_available():
|
|
69
|
+
return "Error: Docker is not installed or not on PATH"
|
|
70
|
+
return await self._manager.read_file(self.path)
|
|
71
|
+
|
|
72
|
+
SandboxReadHandler._manager = manager
|
|
73
|
+
return SandboxReadHandler
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""SandboxManager: Docker container lifecycle for sandbox execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
from .config import SandboxConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SandboxManager:
|
|
15
|
+
"""Manages a session-persistent Docker container for sandboxed execution."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.config = SandboxConfig()
|
|
19
|
+
self._container_id: str | None = None
|
|
20
|
+
self._lock = asyncio.Lock()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def container_running(self) -> bool:
|
|
24
|
+
"""Whether a sandbox container is currently active."""
|
|
25
|
+
return self._container_id is not None
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def docker_available() -> bool:
|
|
29
|
+
"""Check whether the docker CLI is on PATH."""
|
|
30
|
+
return shutil.which("docker") is not None
|
|
31
|
+
|
|
32
|
+
async def _create_container(self) -> str:
|
|
33
|
+
"""Create a new detached container and return its ID."""
|
|
34
|
+
cmd: list[str] = [
|
|
35
|
+
"docker",
|
|
36
|
+
"run",
|
|
37
|
+
"-d",
|
|
38
|
+
"--memory",
|
|
39
|
+
self.config.memory,
|
|
40
|
+
"--cpus",
|
|
41
|
+
self.config.cpus,
|
|
42
|
+
"-w",
|
|
43
|
+
self.config.workdir,
|
|
44
|
+
]
|
|
45
|
+
if not self.config.network:
|
|
46
|
+
cmd.extend(["--network", "none"])
|
|
47
|
+
cmd.extend([self.config.image, "sleep", "infinity"])
|
|
48
|
+
|
|
49
|
+
proc = await asyncio.create_subprocess_exec(
|
|
50
|
+
*cmd,
|
|
51
|
+
stdout=asyncio.subprocess.PIPE,
|
|
52
|
+
stderr=asyncio.subprocess.PIPE,
|
|
53
|
+
)
|
|
54
|
+
stdout, stderr = await proc.communicate()
|
|
55
|
+
if proc.returncode != 0:
|
|
56
|
+
raise RuntimeError(f"Failed to create container: {stderr.decode().strip()}")
|
|
57
|
+
container_id = stdout.decode().strip()[:12]
|
|
58
|
+
logger.info("Created sandbox container %s (image=%s)", container_id, self.config.image)
|
|
59
|
+
return container_id
|
|
60
|
+
|
|
61
|
+
async def _ensure_container(self) -> str:
|
|
62
|
+
"""Return the running container ID, creating one if needed."""
|
|
63
|
+
if self._container_id is not None:
|
|
64
|
+
return self._container_id
|
|
65
|
+
async with self._lock:
|
|
66
|
+
if self._container_id is not None:
|
|
67
|
+
return self._container_id
|
|
68
|
+
self._container_id = await self._create_container()
|
|
69
|
+
return self._container_id
|
|
70
|
+
|
|
71
|
+
async def exec(self, command: str, timeout: int = 30) -> str:
|
|
72
|
+
"""Execute a shell command inside the container."""
|
|
73
|
+
container_id = await self._ensure_container()
|
|
74
|
+
proc = await asyncio.create_subprocess_exec(
|
|
75
|
+
"docker",
|
|
76
|
+
"exec",
|
|
77
|
+
container_id,
|
|
78
|
+
"sh",
|
|
79
|
+
"-c",
|
|
80
|
+
command,
|
|
81
|
+
stdout=asyncio.subprocess.PIPE,
|
|
82
|
+
stderr=asyncio.subprocess.PIPE,
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
86
|
+
except TimeoutError:
|
|
87
|
+
proc.kill()
|
|
88
|
+
return f"[timeout after {timeout}s]"
|
|
89
|
+
|
|
90
|
+
output = ""
|
|
91
|
+
if stdout:
|
|
92
|
+
output += stdout.decode()
|
|
93
|
+
if stderr:
|
|
94
|
+
output += f"\n[stderr]\n{stderr.decode()}"
|
|
95
|
+
if proc.returncode != 0:
|
|
96
|
+
output += f"\n[exit code: {proc.returncode}]"
|
|
97
|
+
return output.strip() or "(no output)"
|
|
98
|
+
|
|
99
|
+
async def write_file(self, path: str, content: str) -> str:
|
|
100
|
+
"""Write content to a file inside the container via stdin piping."""
|
|
101
|
+
container_id = await self._ensure_container()
|
|
102
|
+
proc = await asyncio.create_subprocess_exec(
|
|
103
|
+
"docker",
|
|
104
|
+
"exec",
|
|
105
|
+
"-i",
|
|
106
|
+
container_id,
|
|
107
|
+
"sh",
|
|
108
|
+
"-c",
|
|
109
|
+
f"cat > {path}",
|
|
110
|
+
stdin=asyncio.subprocess.PIPE,
|
|
111
|
+
stdout=asyncio.subprocess.PIPE,
|
|
112
|
+
stderr=asyncio.subprocess.PIPE,
|
|
113
|
+
)
|
|
114
|
+
stdout, stderr = await proc.communicate(input=content.encode())
|
|
115
|
+
if proc.returncode != 0:
|
|
116
|
+
return f"Error writing {path}: {stderr.decode().strip()}"
|
|
117
|
+
return f"Wrote {path}"
|
|
118
|
+
|
|
119
|
+
async def read_file(self, path: str) -> str:
|
|
120
|
+
"""Read a file from inside the container."""
|
|
121
|
+
container_id = await self._ensure_container()
|
|
122
|
+
proc = await asyncio.create_subprocess_exec(
|
|
123
|
+
"docker",
|
|
124
|
+
"exec",
|
|
125
|
+
container_id,
|
|
126
|
+
"cat",
|
|
127
|
+
path,
|
|
128
|
+
stdout=asyncio.subprocess.PIPE,
|
|
129
|
+
stderr=asyncio.subprocess.PIPE,
|
|
130
|
+
)
|
|
131
|
+
stdout, stderr = await proc.communicate()
|
|
132
|
+
if proc.returncode != 0:
|
|
133
|
+
return f"Error reading {path}: {stderr.decode().strip()}"
|
|
134
|
+
return stdout.decode()
|
|
135
|
+
|
|
136
|
+
async def recreate(self) -> str:
|
|
137
|
+
"""Destroy the current container and create a fresh one."""
|
|
138
|
+
await self.close()
|
|
139
|
+
return await self._ensure_container()
|
|
140
|
+
|
|
141
|
+
async def close(self) -> None:
|
|
142
|
+
"""Stop and remove the container if running."""
|
|
143
|
+
if self._container_id is None:
|
|
144
|
+
return
|
|
145
|
+
container_id = self._container_id
|
|
146
|
+
self._container_id = None
|
|
147
|
+
try:
|
|
148
|
+
proc = await asyncio.create_subprocess_exec(
|
|
149
|
+
"docker",
|
|
150
|
+
"rm",
|
|
151
|
+
"-f",
|
|
152
|
+
container_id,
|
|
153
|
+
stdout=asyncio.subprocess.PIPE,
|
|
154
|
+
stderr=asyncio.subprocess.PIPE,
|
|
155
|
+
)
|
|
156
|
+
await proc.communicate()
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.warning("Failed to remove container %s", container_id, exc_info=True)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""DockerPlugin: ToolsPlugin implementation for Docker sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from axio.tool import Tool
|
|
9
|
+
|
|
10
|
+
from .config import SandboxConfig
|
|
11
|
+
from .handler import build_sandbox_exec, build_sandbox_read, build_sandbox_write
|
|
12
|
+
from .manager import SandboxManager
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DockerPlugin:
|
|
18
|
+
"""Dynamic tool provider for Docker sandbox execution.
|
|
19
|
+
|
|
20
|
+
Discovered via the ``axio.tools.settings`` entry point group.
|
|
21
|
+
The TUI interacts with this class through the ToolsPlugin protocol.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._manager = SandboxManager()
|
|
26
|
+
self._tools: list[Tool] = []
|
|
27
|
+
self._config: Any = None
|
|
28
|
+
self._global_config: Any = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def label(self) -> str:
|
|
32
|
+
return "Docker Sandbox"
|
|
33
|
+
|
|
34
|
+
async def init(self, config: Any = None, global_config: Any = None) -> None:
|
|
35
|
+
self._config = config
|
|
36
|
+
self._global_config = global_config
|
|
37
|
+
await self._load_config()
|
|
38
|
+
self._build_tools()
|
|
39
|
+
|
|
40
|
+
async def _load_config(self) -> None:
|
|
41
|
+
"""Load sandbox config from DB (project first, then global)."""
|
|
42
|
+
for db in (self._config, self._global_config):
|
|
43
|
+
if db is None:
|
|
44
|
+
continue
|
|
45
|
+
raw = await db.get_prefix("docker.")
|
|
46
|
+
if not raw:
|
|
47
|
+
continue
|
|
48
|
+
data: dict[str, str] = {}
|
|
49
|
+
for full_key, value in raw.items():
|
|
50
|
+
parts = full_key.split(".", 1)
|
|
51
|
+
if len(parts) == 2:
|
|
52
|
+
data[parts[1]] = value
|
|
53
|
+
if data:
|
|
54
|
+
self._manager.config = SandboxConfig.from_dict(data)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
def _build_tools(self) -> None:
|
|
58
|
+
"""Create the three sandbox tools."""
|
|
59
|
+
self._tools = [
|
|
60
|
+
Tool(
|
|
61
|
+
name="sandbox_exec",
|
|
62
|
+
description="Execute a shell command inside a Docker sandbox container",
|
|
63
|
+
handler=build_sandbox_exec(self._manager),
|
|
64
|
+
),
|
|
65
|
+
Tool(
|
|
66
|
+
name="sandbox_write",
|
|
67
|
+
description="Write content to a file inside the Docker sandbox container",
|
|
68
|
+
handler=build_sandbox_write(self._manager),
|
|
69
|
+
),
|
|
70
|
+
Tool(
|
|
71
|
+
name="sandbox_read",
|
|
72
|
+
description="Read a file from the Docker sandbox container",
|
|
73
|
+
handler=build_sandbox_read(self._manager),
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def all_tools(self) -> list[Tool]:
|
|
79
|
+
return self._tools
|
|
80
|
+
|
|
81
|
+
def settings_screen(self) -> Any:
|
|
82
|
+
from .settings import DockerSettingsScreen
|
|
83
|
+
|
|
84
|
+
return DockerSettingsScreen(self._manager, config=self._config, global_config=self._global_config)
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
await self._manager.close()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""TUI settings screen for Docker sandbox configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Container, Horizontal
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Checkbox, Input, Static
|
|
13
|
+
|
|
14
|
+
_HAS_TEXTUAL = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
_HAS_TEXTUAL = False
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .config import SandboxConfig
|
|
20
|
+
from .manager import SandboxManager
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if _HAS_TEXTUAL:
|
|
24
|
+
|
|
25
|
+
class DockerSettingsScreen(ModalScreen[None]):
|
|
26
|
+
"""Settings screen for Docker sandbox configuration."""
|
|
27
|
+
|
|
28
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
29
|
+
CSS = """
|
|
30
|
+
DockerSettingsScreen { align: center middle; }
|
|
31
|
+
#docker-settings {
|
|
32
|
+
width: 70;
|
|
33
|
+
height: auto;
|
|
34
|
+
max-height: 90%;
|
|
35
|
+
border: heavy $accent;
|
|
36
|
+
background: $panel;
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
}
|
|
39
|
+
#docker-settings Input { margin-bottom: 1; }
|
|
40
|
+
#docker-settings Checkbox { margin-bottom: 1; }
|
|
41
|
+
.docker-buttons { height: auto; margin-top: 1; }
|
|
42
|
+
.docker-buttons Button { margin: 0 1; }
|
|
43
|
+
.docker-container-actions { height: auto; margin-top: 1; margin-bottom: 1; }
|
|
44
|
+
.docker-container-actions Button { margin: 0 1; }
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
manager: SandboxManager,
|
|
50
|
+
config: Any = None,
|
|
51
|
+
global_config: Any = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__()
|
|
54
|
+
self._manager = manager
|
|
55
|
+
self._config = config
|
|
56
|
+
self._global_config = global_config
|
|
57
|
+
|
|
58
|
+
def compose(self) -> ComposeResult:
|
|
59
|
+
cfg = self._manager.config
|
|
60
|
+
docker_ok = self._manager.docker_available()
|
|
61
|
+
status = "[green]Docker available[/]" if docker_ok else "[red]Docker not found[/]"
|
|
62
|
+
running = self._manager.container_running
|
|
63
|
+
|
|
64
|
+
with Container(id="docker-settings"):
|
|
65
|
+
yield Static("[bold]Docker Sandbox Settings[/]")
|
|
66
|
+
yield Static(f"Status: {status}", id="docker-status")
|
|
67
|
+
yield Static(
|
|
68
|
+
"Container: [green]running[/]" if running else "Container: [dim]stopped[/]",
|
|
69
|
+
id="docker-container-status",
|
|
70
|
+
)
|
|
71
|
+
with Horizontal(classes="docker-container-actions"):
|
|
72
|
+
yield Button("Prepare", id="btn-prepare", variant="success", disabled=running)
|
|
73
|
+
yield Button("Stop", id="btn-stop", variant="error", disabled=not running)
|
|
74
|
+
yield Button("Recreate", id="btn-recreate", variant="warning", disabled=not running)
|
|
75
|
+
yield Static("Image:")
|
|
76
|
+
yield Input(value=cfg.image, placeholder="e.g. python:latest", id="docker-image")
|
|
77
|
+
yield Static("Memory limit:")
|
|
78
|
+
yield Input(value=cfg.memory, placeholder="e.g. 256m", id="docker-memory")
|
|
79
|
+
yield Static("CPU limit:")
|
|
80
|
+
yield Input(value=cfg.cpus, placeholder="e.g. 1.0", id="docker-cpus")
|
|
81
|
+
yield Checkbox("Allow network access", cfg.network, id="docker-network")
|
|
82
|
+
with Horizontal(classes="docker-buttons"):
|
|
83
|
+
yield Button("Save", id="btn-save", variant="primary")
|
|
84
|
+
yield Button("Cancel", id="btn-cancel")
|
|
85
|
+
|
|
86
|
+
def on_mount(self) -> None:
|
|
87
|
+
self.query_one("#docker-image", Input).focus()
|
|
88
|
+
|
|
89
|
+
def _update_container_status(self) -> None:
|
|
90
|
+
running = self._manager.container_running
|
|
91
|
+
status_widget = self.query_one("#docker-container-status", Static)
|
|
92
|
+
status_widget.update(
|
|
93
|
+
"Container: [green]running[/]" if running else "Container: [dim]stopped[/]",
|
|
94
|
+
)
|
|
95
|
+
self.query_one("#btn-prepare", Button).disabled = running
|
|
96
|
+
self.query_one("#btn-stop", Button).disabled = not running
|
|
97
|
+
self.query_one("#btn-recreate", Button).disabled = not running
|
|
98
|
+
|
|
99
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
100
|
+
if event.button.id == "btn-cancel":
|
|
101
|
+
self.dismiss(None)
|
|
102
|
+
elif event.button.id == "btn-save":
|
|
103
|
+
self._save()
|
|
104
|
+
elif event.button.id == "btn-prepare":
|
|
105
|
+
self.app.run_worker(self._do_prepare())
|
|
106
|
+
elif event.button.id == "btn-stop":
|
|
107
|
+
self.app.run_worker(self._do_stop())
|
|
108
|
+
elif event.button.id == "btn-recreate":
|
|
109
|
+
self.app.run_worker(self._do_recreate())
|
|
110
|
+
|
|
111
|
+
async def _do_prepare(self) -> None:
|
|
112
|
+
self.notify("Preparing container...")
|
|
113
|
+
try:
|
|
114
|
+
await self._manager._ensure_container()
|
|
115
|
+
self._update_container_status()
|
|
116
|
+
self.notify("Container ready")
|
|
117
|
+
except RuntimeError as exc:
|
|
118
|
+
self.notify(str(exc), severity="error")
|
|
119
|
+
|
|
120
|
+
async def _do_stop(self) -> None:
|
|
121
|
+
await self._manager.close()
|
|
122
|
+
self._update_container_status()
|
|
123
|
+
self.notify("Container stopped")
|
|
124
|
+
|
|
125
|
+
async def _do_recreate(self) -> None:
|
|
126
|
+
self.notify("Recreating container...")
|
|
127
|
+
try:
|
|
128
|
+
await self._manager.recreate()
|
|
129
|
+
self._update_container_status()
|
|
130
|
+
self.notify("Container recreated")
|
|
131
|
+
except RuntimeError as exc:
|
|
132
|
+
self.notify(str(exc), severity="error")
|
|
133
|
+
self._update_container_status()
|
|
134
|
+
|
|
135
|
+
def _save(self) -> None:
|
|
136
|
+
from .config import SandboxConfig
|
|
137
|
+
|
|
138
|
+
image = self.query_one("#docker-image", Input).value.strip()
|
|
139
|
+
memory = self.query_one("#docker-memory", Input).value.strip()
|
|
140
|
+
cpus = self.query_one("#docker-cpus", Input).value.strip()
|
|
141
|
+
network = self.query_one("#docker-network", Checkbox).value
|
|
142
|
+
|
|
143
|
+
cfg = SandboxConfig(
|
|
144
|
+
image=image or "python:latest",
|
|
145
|
+
memory=memory or "256m",
|
|
146
|
+
cpus=cpus or "1.0",
|
|
147
|
+
network=network,
|
|
148
|
+
)
|
|
149
|
+
self._manager.config = cfg
|
|
150
|
+
self.app.run_worker(self._persist(cfg))
|
|
151
|
+
self.dismiss(None)
|
|
152
|
+
|
|
153
|
+
async def _persist(self, cfg: SandboxConfig) -> None:
|
|
154
|
+
db = self._config or self._global_config
|
|
155
|
+
if db is None:
|
|
156
|
+
return
|
|
157
|
+
await db.delete_prefix("docker.")
|
|
158
|
+
for key, value in cfg.to_dict().items():
|
|
159
|
+
await db.set(f"docker.{key}", value)
|
|
160
|
+
|
|
161
|
+
def action_cancel(self) -> None:
|
|
162
|
+
self.dismiss(None)
|
|
163
|
+
|
|
164
|
+
else:
|
|
165
|
+
|
|
166
|
+
class DockerSettingsScreen: # type: ignore[no-redef]
|
|
167
|
+
"""Stub when textual is not installed."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
170
|
+
raise ImportError("textual is required for Docker settings screen")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
axio_tools_docker/__init__.py,sha256=VlydIu0yrzEObMaquRpTTRRqyPIf2BgUJcmZtfRUJRs,204
|
|
2
|
+
axio_tools_docker/config.py,sha256=5SeqSnvszaVtpSJ_JU_cap5EC_suqnX8VH47oQ-W0RA,1546
|
|
3
|
+
axio_tools_docker/handler.py,sha256=gRXEVU5_r3RvzTkzEdaBwk_lYNeXT8xsIMy70wgZNaw,2472
|
|
4
|
+
axio_tools_docker/manager.py,sha256=RnmDM66doxYynTL4rnEAKa_h7lZ1Bqr0VCpDxVRwOIY,5390
|
|
5
|
+
axio_tools_docker/plugin.py,sha256=XSYx-55MGfYNcxf6FJ-hZOiunuijMJRFBwimuUcYAp8,2824
|
|
6
|
+
axio_tools_docker/settings.py,sha256=68GsTim219YyK9-2ono2E5x6Cf9-EHidLxS3VomcJyY,6914
|
|
7
|
+
axio_tools_docker-0.1.0.dist-info/METADATA,sha256=b2FyGPX2a6-s0kaPQ1YaETiK-lkS0zhVoYnCKixdpbA,179
|
|
8
|
+
axio_tools_docker-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
axio_tools_docker-0.1.0.dist-info/entry_points.txt,sha256=IMGBwH0xSL1wAvarV1r8s8sVMW4NjgxW-_6S8uywOys,69
|
|
10
|
+
axio_tools_docker-0.1.0.dist-info/licenses/LICENSE,sha256=ddOkAXgIM2QzvUDPydeis3tJXfhHxt1wKnmGjbwPPxQ,1074
|
|
11
|
+
axio_tools_docker-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Axio contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|