Spaces:
Runtime error
Runtime error
tech-envision
commited on
Commit
·
4bec4d3
1
Parent(s):
706c060
feat(vm): persist docker container across restarts
Browse files- README.md +9 -4
- src/config.py +3 -0
- src/vm.py +60 -15
README.md
CHANGED
|
@@ -14,10 +14,10 @@ conversations can be resumed with context. One example tool is included:
|
|
| 14 |
while the command runs.
|
| 15 |
The VM is created when a chat session starts and reused for all subsequent
|
| 16 |
tool calls. When ``PERSIST_VMS`` is enabled (default), each user keeps the
|
| 17 |
-
same container across multiple chat sessions
|
| 18 |
-
filesystem changes remain available. The
|
| 19 |
-
``pip`` so complex tasks can be scripted using
|
| 20 |
-
terminal.
|
| 21 |
|
| 22 |
Sessions share state through an in-memory registry so that only one generation
|
| 23 |
can run at a time. Messages sent while a response is being produced are
|
|
@@ -80,6 +80,11 @@ back to ``python:3.11-slim``. This base image includes Python and ``pip`` so
|
|
| 80 |
packages can be installed immediately. The container has network access enabled
|
| 81 |
which allows fetching additional dependencies as needed.
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
Set ``PERSIST_VMS=0`` to revert to the previous behaviour where containers are
|
| 84 |
stopped once no sessions are using them.
|
| 85 |
|
|
|
|
| 14 |
while the command runs.
|
| 15 |
The VM is created when a chat session starts and reused for all subsequent
|
| 16 |
tool calls. When ``PERSIST_VMS`` is enabled (default), each user keeps the
|
| 17 |
+
same container across multiple chat sessions and across application restarts,
|
| 18 |
+
so any installed packages and filesystem changes remain available. The
|
| 19 |
+
environment includes Python and ``pip`` so complex tasks can be scripted using
|
| 20 |
+
Python directly inside the terminal.
|
| 21 |
|
| 22 |
Sessions share state through an in-memory registry so that only one generation
|
| 23 |
can run at a time. Messages sent while a response is being produced are
|
|
|
|
| 80 |
packages can be installed immediately. The container has network access enabled
|
| 81 |
which allows fetching additional dependencies as needed.
|
| 82 |
|
| 83 |
+
When ``PERSIST_VMS`` is ``1`` (default), containers are kept around and reused
|
| 84 |
+
across application restarts. Each user is assigned a stable container name, so
|
| 85 |
+
packages installed or files created inside the VM remain available the next
|
| 86 |
+
time the application starts. Set ``VM_STATE_DIR`` to specify the host directory
|
| 87 |
+
used for per-user persistent storage mounted inside the VM at ``/state``.
|
| 88 |
Set ``PERSIST_VMS=0`` to revert to the previous behaviour where containers are
|
| 89 |
stopped once no sessions are using them.
|
| 90 |
|
src/config.py
CHANGED
|
@@ -11,6 +11,9 @@ NUM_CTX: Final[int] = int(os.getenv("OLLAMA_NUM_CTX", "32000"))
|
|
| 11 |
UPLOAD_DIR: Final[str] = os.getenv("UPLOAD_DIR", str(Path.cwd() / "uploads"))
|
| 12 |
VM_IMAGE: Final[str] = os.getenv("VM_IMAGE", "python:3.11")
|
| 13 |
PERSIST_VMS: Final[bool] = os.getenv("PERSIST_VMS", "1") == "1"
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
SYSTEM_PROMPT: Final[str] = (
|
| 16 |
"You are Starlette, a professional AI assistant with advanced tool orchestration. "
|
|
|
|
| 11 |
UPLOAD_DIR: Final[str] = os.getenv("UPLOAD_DIR", str(Path.cwd() / "uploads"))
|
| 12 |
VM_IMAGE: Final[str] = os.getenv("VM_IMAGE", "python:3.11")
|
| 13 |
PERSIST_VMS: Final[bool] = os.getenv("PERSIST_VMS", "1") == "1"
|
| 14 |
+
VM_STATE_DIR: Final[str] = os.getenv(
|
| 15 |
+
"VM_STATE_DIR", str(Path.cwd() / "vm_state")
|
| 16 |
+
)
|
| 17 |
|
| 18 |
SYSTEM_PROMPT: Final[str] = (
|
| 19 |
"You are Starlette, a professional AI assistant with advanced tool orchestration. "
|
src/vm.py
CHANGED
|
@@ -8,13 +8,19 @@ from pathlib import Path
|
|
| 8 |
|
| 9 |
from threading import Lock
|
| 10 |
|
| 11 |
-
from .config import UPLOAD_DIR, VM_IMAGE, PERSIST_VMS
|
| 12 |
|
| 13 |
from .log import get_logger
|
| 14 |
|
| 15 |
_LOG = get_logger(__name__)
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
class LinuxVM:
|
| 19 |
"""Manage a lightweight Docker-based VM.
|
| 20 |
|
|
@@ -23,13 +29,18 @@ class LinuxVM:
|
|
| 23 |
"""
|
| 24 |
|
| 25 |
def __init__(
|
| 26 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 27 |
) -> None:
|
| 28 |
self._image = image
|
| 29 |
-
self._name = f"chat-vm-{
|
| 30 |
self._running = False
|
| 31 |
self._host_dir = Path(host_dir)
|
| 32 |
self._host_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
| 33 |
|
| 34 |
def start(self) -> None:
|
| 35 |
"""Start the VM if it is not already running."""
|
|
@@ -37,6 +48,25 @@ class LinuxVM:
|
|
| 37 |
return
|
| 38 |
|
| 39 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
subprocess.run(
|
| 41 |
["docker", "pull", self._image],
|
| 42 |
check=False,
|
|
@@ -53,6 +83,8 @@ class LinuxVM:
|
|
| 53 |
self._name,
|
| 54 |
"-v",
|
| 55 |
f"{self._host_dir}:/data",
|
|
|
|
|
|
|
| 56 |
self._image,
|
| 57 |
"sleep",
|
| 58 |
"infinity",
|
|
@@ -130,13 +162,22 @@ class LinuxVM:
|
|
| 130 |
if not self._running:
|
| 131 |
return
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
self._running = False
|
| 141 |
|
| 142 |
def __enter__(self) -> "LinuxVM":
|
|
@@ -161,7 +202,10 @@ class VMRegistry:
|
|
| 161 |
with cls._lock:
|
| 162 |
vm = cls._vms.get(username)
|
| 163 |
if vm is None:
|
| 164 |
-
vm = LinuxVM(
|
|
|
|
|
|
|
|
|
|
| 165 |
cls._vms[username] = vm
|
| 166 |
cls._counts[username] = 0
|
| 167 |
cls._counts[username] += 1
|
|
@@ -183,15 +227,16 @@ class VMRegistry:
|
|
| 183 |
cls._counts[username] = 0
|
| 184 |
if not PERSIST_VMS:
|
| 185 |
vm.stop()
|
| 186 |
-
|
| 187 |
-
|
| 188 |
|
| 189 |
@classmethod
|
| 190 |
def shutdown_all(cls) -> None:
|
| 191 |
"""Stop and remove all managed VMs."""
|
| 192 |
|
| 193 |
with cls._lock:
|
| 194 |
-
|
| 195 |
-
vm.
|
|
|
|
| 196 |
cls._vms.clear()
|
| 197 |
cls._counts.clear()
|
|
|
|
| 8 |
|
| 9 |
from threading import Lock
|
| 10 |
|
| 11 |
+
from .config import UPLOAD_DIR, VM_IMAGE, PERSIST_VMS, VM_STATE_DIR
|
| 12 |
|
| 13 |
from .log import get_logger
|
| 14 |
|
| 15 |
_LOG = get_logger(__name__)
|
| 16 |
|
| 17 |
|
| 18 |
+
def _sanitize(name: str) -> str:
|
| 19 |
+
"""Return a Docker-safe name fragment."""
|
| 20 |
+
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
|
| 21 |
+
return "".join(c if c in allowed else "_" for c in name)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
class LinuxVM:
|
| 25 |
"""Manage a lightweight Docker-based VM.
|
| 26 |
|
|
|
|
| 29 |
"""
|
| 30 |
|
| 31 |
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
username: str,
|
| 34 |
+
image: str = VM_IMAGE,
|
| 35 |
+
host_dir: str = UPLOAD_DIR,
|
| 36 |
) -> None:
|
| 37 |
self._image = image
|
| 38 |
+
self._name = f"chat-vm-{_sanitize(username)}"
|
| 39 |
self._running = False
|
| 40 |
self._host_dir = Path(host_dir)
|
| 41 |
self._host_dir.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
self._state_dir = Path(VM_STATE_DIR) / _sanitize(username)
|
| 43 |
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
| 44 |
|
| 45 |
def start(self) -> None:
|
| 46 |
"""Start the VM if it is not already running."""
|
|
|
|
| 48 |
return
|
| 49 |
|
| 50 |
try:
|
| 51 |
+
inspect = subprocess.run(
|
| 52 |
+
["docker", "inspect", "-f", "{{.State.Running}}", self._name],
|
| 53 |
+
capture_output=True,
|
| 54 |
+
text=True,
|
| 55 |
+
)
|
| 56 |
+
if inspect.returncode == 0:
|
| 57 |
+
if inspect.stdout.strip() == "true":
|
| 58 |
+
self._running = True
|
| 59 |
+
return
|
| 60 |
+
subprocess.run(
|
| 61 |
+
["docker", "start", self._name],
|
| 62 |
+
check=True,
|
| 63 |
+
stdout=subprocess.PIPE,
|
| 64 |
+
stderr=subprocess.PIPE,
|
| 65 |
+
text=True,
|
| 66 |
+
)
|
| 67 |
+
self._running = True
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
subprocess.run(
|
| 71 |
["docker", "pull", self._image],
|
| 72 |
check=False,
|
|
|
|
| 83 |
self._name,
|
| 84 |
"-v",
|
| 85 |
f"{self._host_dir}:/data",
|
| 86 |
+
"-v",
|
| 87 |
+
f"{self._state_dir}:/state",
|
| 88 |
self._image,
|
| 89 |
"sleep",
|
| 90 |
"infinity",
|
|
|
|
| 162 |
if not self._running:
|
| 163 |
return
|
| 164 |
|
| 165 |
+
if PERSIST_VMS:
|
| 166 |
+
subprocess.run(
|
| 167 |
+
["docker", "stop", self._name],
|
| 168 |
+
check=False,
|
| 169 |
+
stdout=subprocess.PIPE,
|
| 170 |
+
stderr=subprocess.PIPE,
|
| 171 |
+
text=True,
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
subprocess.run(
|
| 175 |
+
["docker", "rm", "-f", self._name],
|
| 176 |
+
check=False,
|
| 177 |
+
stdout=subprocess.PIPE,
|
| 178 |
+
stderr=subprocess.PIPE,
|
| 179 |
+
text=True,
|
| 180 |
+
)
|
| 181 |
self._running = False
|
| 182 |
|
| 183 |
def __enter__(self) -> "LinuxVM":
|
|
|
|
| 202 |
with cls._lock:
|
| 203 |
vm = cls._vms.get(username)
|
| 204 |
if vm is None:
|
| 205 |
+
vm = LinuxVM(
|
| 206 |
+
username,
|
| 207 |
+
host_dir=str(Path(UPLOAD_DIR) / username),
|
| 208 |
+
)
|
| 209 |
cls._vms[username] = vm
|
| 210 |
cls._counts[username] = 0
|
| 211 |
cls._counts[username] += 1
|
|
|
|
| 227 |
cls._counts[username] = 0
|
| 228 |
if not PERSIST_VMS:
|
| 229 |
vm.stop()
|
| 230 |
+
del cls._vms[username]
|
| 231 |
+
del cls._counts[username]
|
| 232 |
|
| 233 |
@classmethod
|
| 234 |
def shutdown_all(cls) -> None:
|
| 235 |
"""Stop and remove all managed VMs."""
|
| 236 |
|
| 237 |
with cls._lock:
|
| 238 |
+
if not PERSIST_VMS:
|
| 239 |
+
for vm in cls._vms.values():
|
| 240 |
+
vm.stop()
|
| 241 |
cls._vms.clear()
|
| 242 |
cls._counts.clear()
|