remote.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import base64
  2. import io
  3. import tarfile
  4. import time
  5. import requests
  6. from openhands.core.exceptions import AgentRuntimeBuildError
  7. from openhands.core.logger import openhands_logger as logger
  8. from openhands.runtime.builder import RuntimeBuilder
  9. from openhands.runtime.utils.request import send_request
  10. from openhands.utils.shutdown_listener import (
  11. should_continue,
  12. sleep_if_should_continue,
  13. )
  14. class RemoteRuntimeBuilder(RuntimeBuilder):
  15. """This class interacts with the remote Runtime API for building and managing container images."""
  16. def __init__(self, api_url: str, api_key: str):
  17. self.api_url = api_url
  18. self.api_key = api_key
  19. self.session = requests.Session()
  20. self.session.headers.update({'X-API-Key': self.api_key})
  21. def build(
  22. self,
  23. path: str,
  24. tags: list[str],
  25. platform: str | None = None,
  26. extra_build_args: list[str] | None = None,
  27. ) -> str:
  28. """Builds a Docker image using the Runtime API's /build endpoint."""
  29. # Create a tar archive of the build context
  30. tar_buffer = io.BytesIO()
  31. with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar:
  32. tar.add(path, arcname='.')
  33. tar_buffer.seek(0)
  34. # Encode the tar file as base64
  35. base64_encoded_tar = base64.b64encode(tar_buffer.getvalue()).decode('utf-8')
  36. # Prepare the multipart form data
  37. files = [
  38. ('context', ('context.tar.gz', base64_encoded_tar)),
  39. ('target_image', (None, tags[0])),
  40. ]
  41. # Add additional tags if present
  42. for tag in tags[1:]:
  43. files.append(('tags', (None, tag)))
  44. # Send the POST request to /build (Begins the build process)
  45. try:
  46. response = send_request(
  47. self.session,
  48. 'POST',
  49. f'{self.api_url}/build',
  50. files=files,
  51. timeout=30,
  52. )
  53. except requests.exceptions.HTTPError as e:
  54. if e.response.status_code == 429:
  55. logger.warning('Build was rate limited. Retrying in 30 seconds.')
  56. time.sleep(30)
  57. return self.build(path, tags, platform)
  58. else:
  59. raise e
  60. build_data = response.json()
  61. build_id = build_data['build_id']
  62. logger.info(f'Build initiated with ID: {build_id}')
  63. # Poll /build_status until the build is complete
  64. start_time = time.time()
  65. timeout = 30 * 60 # 20 minutes in seconds
  66. while should_continue():
  67. if time.time() - start_time > timeout:
  68. logger.error('Build timed out after 30 minutes')
  69. raise AgentRuntimeBuildError('Build timed out after 30 minutes')
  70. status_response = send_request(
  71. self.session,
  72. 'GET',
  73. f'{self.api_url}/build_status',
  74. params={'build_id': build_id},
  75. )
  76. if status_response.status_code != 200:
  77. logger.error(f'Failed to get build status: {status_response.text}')
  78. raise AgentRuntimeBuildError(
  79. f'Failed to get build status: {status_response.text}'
  80. )
  81. status_data = status_response.json()
  82. status = status_data['status']
  83. logger.info(f'Build status: {status}')
  84. if status == 'SUCCESS':
  85. logger.debug(f"Successfully built {status_data['image']}")
  86. return status_data['image']
  87. elif status in [
  88. 'FAILURE',
  89. 'INTERNAL_ERROR',
  90. 'TIMEOUT',
  91. 'CANCELLED',
  92. 'EXPIRED',
  93. ]:
  94. error_message = status_data.get(
  95. 'error', f'Build failed with status: {status}. Build ID: {build_id}'
  96. )
  97. logger.error(error_message)
  98. raise AgentRuntimeBuildError(error_message)
  99. # Wait before polling again
  100. sleep_if_should_continue(30)
  101. raise AgentRuntimeBuildError(
  102. 'Build interrupted (likely received SIGTERM or SIGINT).'
  103. )
  104. def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
  105. """Checks if an image exists in the remote registry using the /image_exists endpoint."""
  106. params = {'image': image_name}
  107. response = send_request(
  108. self.session,
  109. 'GET',
  110. f'{self.api_url}/image_exists',
  111. params=params,
  112. )
  113. if response.status_code != 200:
  114. logger.error(f'Failed to check image existence: {response.text}')
  115. raise AgentRuntimeBuildError(
  116. f'Failed to check image existence: {response.text}'
  117. )
  118. result = response.json()
  119. if result['exists']:
  120. logger.debug(
  121. f"Image {image_name} exists. "
  122. f"Uploaded at: {result['image']['upload_time']}, "
  123. f"Size: {result['image']['image_size_bytes'] / 1024 / 1024:.2f} MB"
  124. )
  125. else:
  126. logger.debug(f'Image {image_name} does not exist.')
  127. return result['exists']