remote.py 4.8 KB

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