exec_box.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import atexit
  2. import os
  3. import shlex
  4. import sys
  5. import tarfile
  6. import time
  7. import uuid
  8. from collections import namedtuple
  9. from glob import glob
  10. import docker
  11. from opendevin.const.guide_url import TROUBLESHOOTING_URL
  12. from opendevin.core.config import config
  13. from opendevin.core.exceptions import SandboxInvalidBackgroundCommandError
  14. from opendevin.core.logger import opendevin_logger as logger
  15. from opendevin.core.schema import CancellableStream
  16. from opendevin.runtime.docker.process import DockerProcess, Process
  17. from opendevin.runtime.sandbox import Sandbox
  18. # FIXME these are not used, should we remove them?
  19. InputType = namedtuple('InputType', ['content'])
  20. OutputType = namedtuple('OutputType', ['content'])
  21. ExecResult = namedtuple('ExecResult', 'exit_code,output')
  22. """ A result of Container.exec_run with the properties ``exit_code`` and
  23. ``output``. """
  24. class DockerExecCancellableStream(CancellableStream):
  25. # Reference: https://github.com/docker/docker-py/issues/1989
  26. def __init__(self, _client, _id, _output):
  27. super().__init__(self.read_output())
  28. self._id = _id
  29. self._client = _client
  30. self._output = _output
  31. def close(self):
  32. self.closed = True
  33. def exit_code(self):
  34. return self.inspect()['ExitCode']
  35. def inspect(self):
  36. return self._client.api.exec_inspect(self._id)
  37. def read_output(self):
  38. for chunk in self._output:
  39. yield chunk.decode('utf-8')
  40. def container_exec_run(
  41. container,
  42. cmd,
  43. stdout=True,
  44. stderr=True,
  45. stdin=False,
  46. tty=False,
  47. privileged=False,
  48. user='',
  49. detach=False,
  50. stream=False,
  51. socket=False,
  52. environment=None,
  53. workdir=None,
  54. ) -> ExecResult:
  55. exec_id = container.client.api.exec_create(
  56. container.id,
  57. cmd,
  58. stdout=stdout,
  59. stderr=stderr,
  60. stdin=stdin,
  61. tty=tty,
  62. privileged=privileged,
  63. user=user,
  64. environment=environment,
  65. workdir=workdir,
  66. )['Id']
  67. output = container.client.api.exec_start(
  68. exec_id, detach=detach, tty=tty, stream=stream, socket=socket
  69. )
  70. if stream:
  71. return ExecResult(
  72. None, DockerExecCancellableStream(container.client, exec_id, output)
  73. )
  74. if socket:
  75. return ExecResult(None, output)
  76. return ExecResult(container.client.api.exec_inspect(exec_id)['ExitCode'], output)
  77. class DockerExecBox(Sandbox):
  78. instance_id: str
  79. container_image: str
  80. container_name_prefix = 'opendevin-sandbox-'
  81. container_name: str
  82. container: docker.models.containers.Container
  83. docker_client: docker.DockerClient
  84. cur_background_id = 0
  85. background_commands: dict[int, Process] = {}
  86. def __init__(
  87. self,
  88. container_image: str | None = None,
  89. timeout: int = 120,
  90. sid: str | None = None,
  91. ):
  92. # Initialize docker client. Throws an exception if Docker is not reachable.
  93. try:
  94. self.docker_client = docker.from_env()
  95. except Exception as ex:
  96. logger.exception(
  97. f'Error creating controller. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information.',
  98. exc_info=False,
  99. )
  100. raise ex
  101. self.instance_id = (
  102. sid + str(uuid.uuid4()) if sid is not None else str(uuid.uuid4())
  103. )
  104. # TODO: this timeout is actually essential - need a better way to set it
  105. # if it is too short, the container may still waiting for previous
  106. # command to finish (e.g. apt-get update)
  107. # if it is too long, the user may have to wait for a unnecessary long time
  108. self.timeout = timeout
  109. self.container_image = (
  110. config.sandbox_container_image
  111. if container_image is None
  112. else container_image
  113. )
  114. self.container_name = self.container_name_prefix + self.instance_id
  115. logger.info(
  116. 'Starting Docker container with image %s, sandbox workspace dir=%s',
  117. self.container_image,
  118. self.sandbox_workspace_dir,
  119. )
  120. # always restart the container, cuz the initial be regarded as a new session
  121. self.restart_docker_container()
  122. if self.run_as_devin:
  123. self.setup_devin_user()
  124. atexit.register(self.close)
  125. super().__init__()
  126. def setup_devin_user(self):
  127. cmds = [
  128. f'useradd --shell /bin/bash -u {self.user_id} -o -c "" -m devin',
  129. r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers",
  130. 'sudo adduser devin sudo',
  131. ]
  132. for cmd in cmds:
  133. exit_code, logs = self.container.exec_run(
  134. ['/bin/bash', '-c', cmd],
  135. workdir=self.sandbox_workspace_dir,
  136. environment=self._env,
  137. )
  138. if exit_code != 0:
  139. raise Exception(f'Failed to setup devin user: {logs}')
  140. def get_exec_cmd(self, cmd: str) -> list[str]:
  141. if self.run_as_devin:
  142. return ['su', 'devin', '-c', cmd]
  143. else:
  144. return ['/bin/bash', '-c', cmd]
  145. def read_logs(self, id) -> str:
  146. if id not in self.background_commands:
  147. raise SandboxInvalidBackgroundCommandError()
  148. bg_cmd = self.background_commands[id]
  149. return bg_cmd.read_logs()
  150. def execute(
  151. self, cmd: str, stream: bool = False, timeout: int | None = None
  152. ) -> tuple[int, str | CancellableStream]:
  153. timeout = timeout if timeout is not None else self.timeout
  154. wrapper = f'timeout {self.timeout}s bash -c {shlex.quote(cmd)}'
  155. _exit_code, _output = container_exec_run(
  156. self.container,
  157. wrapper,
  158. stream=stream,
  159. workdir=self.sandbox_workspace_dir,
  160. environment=self._env,
  161. )
  162. if stream:
  163. return _exit_code, _output
  164. print(_output)
  165. _output = _output.decode('utf-8')
  166. if _output.endswith('\n'):
  167. _output = _output[:-1]
  168. return _exit_code, _output
  169. def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
  170. # mkdir -p sandbox_dest if it doesn't exist
  171. exit_code, logs = self.container.exec_run(
  172. ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'],
  173. workdir=self.sandbox_workspace_dir,
  174. environment=self._env,
  175. )
  176. if exit_code != 0:
  177. raise Exception(
  178. f'Failed to create directory {sandbox_dest} in sandbox: {logs}'
  179. )
  180. if recursive:
  181. assert os.path.isdir(
  182. host_src
  183. ), 'Source must be a directory when recursive is True'
  184. files = glob(host_src + '/**/*', recursive=True)
  185. srcname = os.path.basename(host_src)
  186. tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
  187. with tarfile.open(tar_filename, mode='w') as tar:
  188. for file in files:
  189. tar.add(
  190. file, arcname=os.path.relpath(file, os.path.dirname(host_src))
  191. )
  192. else:
  193. assert os.path.isfile(
  194. host_src
  195. ), 'Source must be a file when recursive is False'
  196. srcname = os.path.basename(host_src)
  197. tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
  198. with tarfile.open(tar_filename, mode='w') as tar:
  199. tar.add(host_src, arcname=srcname)
  200. with open(tar_filename, 'rb') as f:
  201. data = f.read()
  202. self.container.put_archive(os.path.dirname(sandbox_dest), data)
  203. os.remove(tar_filename)
  204. def execute_in_background(self, cmd: str) -> Process:
  205. result = self.container.exec_run(
  206. self.get_exec_cmd(cmd),
  207. socket=True,
  208. workdir=self.sandbox_workspace_dir,
  209. environment=self._env,
  210. )
  211. result.output._sock.setblocking(0)
  212. pid = self.get_pid(cmd)
  213. bg_cmd = DockerProcess(self.cur_background_id, cmd, result, pid)
  214. self.background_commands[bg_cmd.pid] = bg_cmd
  215. self.cur_background_id += 1
  216. return bg_cmd
  217. def get_pid(self, cmd):
  218. exec_result = self.container.exec_run('ps aux', environment=self._env)
  219. processes = exec_result.output.decode('utf-8').splitlines()
  220. cmd = ' '.join(self.get_exec_cmd(cmd))
  221. for process in processes:
  222. if cmd in process:
  223. pid = process.split()[1] # second column is the pid
  224. return pid
  225. return None
  226. def kill_background(self, id: int) -> Process:
  227. if id not in self.background_commands:
  228. raise SandboxInvalidBackgroundCommandError()
  229. bg_cmd = self.background_commands[id]
  230. if bg_cmd.pid is not None:
  231. self.container.exec_run(
  232. f'kill -9 {bg_cmd.pid}',
  233. workdir=self.sandbox_workspace_dir,
  234. environment=self._env,
  235. )
  236. assert isinstance(bg_cmd, DockerProcess)
  237. bg_cmd.result.output.close()
  238. self.background_commands.pop(id)
  239. return bg_cmd
  240. def stop_docker_container(self):
  241. try:
  242. container = self.docker_client.containers.get(self.container_name)
  243. container.stop()
  244. container.remove()
  245. elapsed = 0
  246. while container.status != 'exited':
  247. time.sleep(1)
  248. elapsed += 1
  249. if elapsed > self.timeout:
  250. break
  251. container = self.docker_client.containers.get(self.container_name)
  252. except docker.errors.NotFound:
  253. pass
  254. def is_container_running(self):
  255. try:
  256. container = self.docker_client.containers.get(self.container_name)
  257. if container.status == 'running':
  258. self.container = container
  259. return True
  260. return False
  261. except docker.errors.NotFound:
  262. return False
  263. def restart_docker_container(self):
  264. try:
  265. self.stop_docker_container()
  266. logger.info('Container stopped')
  267. except docker.errors.DockerException as e:
  268. logger.exception('Failed to stop container', exc_info=False)
  269. raise e
  270. try:
  271. # start the container
  272. mount_dir = config.workspace_mount_path
  273. self.container = self.docker_client.containers.run(
  274. self.container_image,
  275. command='tail -f /dev/null',
  276. network_mode='host',
  277. working_dir=self.sandbox_workspace_dir,
  278. name=self.container_name,
  279. detach=True,
  280. volumes={mount_dir: {'bind': self.sandbox_workspace_dir, 'mode': 'rw'}},
  281. )
  282. logger.info('Container started')
  283. except Exception as ex:
  284. logger.exception('Failed to start container', exc_info=False)
  285. raise ex
  286. # wait for container to be ready
  287. elapsed = 0
  288. while self.container.status != 'running':
  289. if self.container.status == 'exited':
  290. logger.info('container exited')
  291. logger.info('container logs:')
  292. logger.info(self.container.logs())
  293. break
  294. time.sleep(1)
  295. elapsed += 1
  296. self.container = self.docker_client.containers.get(self.container_name)
  297. if elapsed > self.timeout:
  298. break
  299. if self.container.status != 'running':
  300. raise Exception('Failed to start container')
  301. # clean up the container, cannot do it in __del__ because the python interpreter is already shutting down
  302. def close(self):
  303. containers = self.docker_client.containers.list(all=True)
  304. for container in containers:
  305. try:
  306. if container.name.startswith(self.container_name_prefix):
  307. container.remove(force=True)
  308. except docker.errors.NotFound:
  309. pass
  310. def get_working_directory(self):
  311. return self.sandbox_workspace_dir
  312. @property
  313. def user_id(self):
  314. return config.sandbox_user_id
  315. @property
  316. def run_as_devin(self):
  317. # FIXME: On some containers, the devin user doesn't have enough permission, e.g. to install packages
  318. # How do we make this more flexible?
  319. return config.run_as_devin
  320. @property
  321. def sandbox_workspace_dir(self):
  322. return config.workspace_mount_path_in_sandbox
  323. if __name__ == '__main__':
  324. try:
  325. exec_box = DockerExecBox()
  326. except Exception as e:
  327. logger.exception('Failed to start Docker container: %s', e)
  328. sys.exit(1)
  329. logger.info(
  330. "Interactive Docker container started. Type 'exit' or use Ctrl+C to exit."
  331. )
  332. bg_cmd = exec_box.execute_in_background(
  333. "while true; do echo -n '.' && sleep 1; done"
  334. )
  335. sys.stdout.flush()
  336. try:
  337. while True:
  338. try:
  339. user_input = input('>>> ')
  340. except EOFError:
  341. logger.info('Exiting...')
  342. break
  343. if user_input.lower() == 'exit':
  344. logger.info('Exiting...')
  345. break
  346. if user_input.lower() == 'kill':
  347. exec_box.kill_background(bg_cmd.pid)
  348. logger.info('Background process killed')
  349. continue
  350. exit_code, output = exec_box.execute(user_input)
  351. logger.info('exit code: %d', exit_code)
  352. logger.info(output)
  353. if bg_cmd.pid in exec_box.background_commands:
  354. logs = exec_box.read_logs(bg_cmd.pid)
  355. logger.info('background logs: %s', logs)
  356. sys.stdout.flush()
  357. except KeyboardInterrupt:
  358. logger.info('Exiting...')
  359. exec_box.close()