|
|
@@ -12,6 +12,10 @@ from jinja2 import Environment, FileSystemLoader
|
|
|
import opendevin
|
|
|
from opendevin.core.logger import opendevin_logger as logger
|
|
|
|
|
|
+RUNTIME_IMAGE_REPO = os.getenv(
|
|
|
+ 'OD_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/opendevin/od_runtime'
|
|
|
+)
|
|
|
+
|
|
|
|
|
|
def _get_package_version():
|
|
|
"""Read the version from pyproject.toml as the other one may be outdated."""
|
|
|
@@ -128,107 +132,86 @@ def prep_docker_build_folder(
|
|
|
|
|
|
|
|
|
def _build_sandbox_image(
|
|
|
- base_image: str,
|
|
|
- target_image_name: str,
|
|
|
+ docker_folder: str,
|
|
|
docker_client: docker.DockerClient,
|
|
|
- skip_init: bool = False,
|
|
|
- extra_deps: str | None = None,
|
|
|
+ target_image_repo: str,
|
|
|
+ target_image_hash_tag: str,
|
|
|
+ target_image_tag: str,
|
|
|
) -> str:
|
|
|
- """Build the sandbox image and return the *hash* docker image name.
|
|
|
-
|
|
|
- The hash is calculated based on the contents of the docker build folder (source code and Dockerfile). This is useful to help prevent rebuilding the image when the source code and Dockerfile are unchanged.
|
|
|
+ """Build the sandbox image.
|
|
|
+
|
|
|
+ The image will be tagged as both:
|
|
|
+ - target_image_repo:target_image_hash_tag
|
|
|
+ - target_image_repo:target_image_tag
|
|
|
+
|
|
|
+ Args:
|
|
|
+ docker_folder: str: the path to the docker build folder
|
|
|
+ docker_client: docker.DockerClient: the docker client
|
|
|
+ target_image_repo: str: the repository name for the target image
|
|
|
+ target_image_hash_tag: str: the *hash* tag for the target image that is calculated based
|
|
|
+ on the contents of the docker build folder (source code and Dockerfile)
|
|
|
+ e.g., ubuntu:latest -> od_runtime:1234567890abcdef
|
|
|
+ target_image_tag: str: the tag for the target image that's generic and based on the base image name
|
|
|
+ e.g., ubuntu:latest -> od_runtime:ubuntu_tag_latest
|
|
|
"""
|
|
|
- target_repo, target_image_tag = target_image_name.split(':')
|
|
|
+ # 1. Always directly build and tag using the dir_hash
|
|
|
+ target_image_hash_name = f'{target_image_repo}:{target_image_hash_tag}'
|
|
|
try:
|
|
|
- with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
- if skip_init:
|
|
|
- logger.info(
|
|
|
- f'Reusing existing od_sandbox image [{target_image_name}] but will update the source code in it.'
|
|
|
- )
|
|
|
- else:
|
|
|
- logger.info(f'Building agnostic sandbox image: {target_image_name}')
|
|
|
-
|
|
|
- dir_hash = prep_docker_build_folder(
|
|
|
- temp_dir, base_image, skip_init=skip_init, extra_deps=extra_deps
|
|
|
- )
|
|
|
- # Use dir_hash as an alternative tag for the image
|
|
|
- # This is useful to help prevent rebuilding the image when the source code/Dockerfile is the same
|
|
|
- target_image_hash_name = f'{target_repo}:{dir_hash}'
|
|
|
-
|
|
|
- # Check if the hash image exists
|
|
|
- if _check_image_exists(target_image_hash_name, docker_client):
|
|
|
- logger.info(f'Image {target_image_hash_name} exists, skipping build.')
|
|
|
- else:
|
|
|
- logger.info(
|
|
|
- f'Image {target_image_name} does not exist, neither does its hash {target_image_hash_name}.\n'
|
|
|
- 'Building the image...'
|
|
|
- )
|
|
|
-
|
|
|
- api_client = docker_client.api
|
|
|
- build_logs = api_client.build(
|
|
|
- path=temp_dir,
|
|
|
- tag=target_image_hash_name,
|
|
|
- rm=True,
|
|
|
- decode=True,
|
|
|
- # do not use cache when skip_init is True (i.e., when we want to update the source code in the existing image)
|
|
|
- nocache=skip_init,
|
|
|
- )
|
|
|
-
|
|
|
- if skip_init:
|
|
|
- logger.info(
|
|
|
- f'Rebuilding existing od_sandbox image [{target_image_name}] to update the source code.'
|
|
|
- )
|
|
|
- for log in build_logs:
|
|
|
- if 'stream' in log:
|
|
|
- print(log['stream'].strip())
|
|
|
- elif 'error' in log:
|
|
|
- logger.error(log['error'].strip())
|
|
|
- else:
|
|
|
- logger.info(str(log))
|
|
|
-
|
|
|
- logger.info(f'Image {target_image_hash_name} build finished.')
|
|
|
- image = docker_client.images.get(target_image_hash_name)
|
|
|
- image.tag(target_repo, target_image_tag)
|
|
|
- logger.info(
|
|
|
- f'Tagged image {target_image_hash_name} --> {target_image_name}'
|
|
|
- )
|
|
|
-
|
|
|
- # check if the image is built successfully
|
|
|
- image = docker_client.images.get(target_image_hash_name)
|
|
|
- if image is None:
|
|
|
- raise RuntimeError(
|
|
|
- f'Build failed: Image {target_image_hash_name} / {target_image_name} not found'
|
|
|
- )
|
|
|
- logger.info(
|
|
|
- f'Image {target_image_name} (hash: {target_image_hash_name}) built successfully'
|
|
|
+ build_logs = docker_client.api.build(
|
|
|
+ path=docker_folder,
|
|
|
+ tag=target_image_hash_name,
|
|
|
+ rm=True,
|
|
|
+ decode=True,
|
|
|
)
|
|
|
- return target_image_hash_name
|
|
|
except docker.errors.BuildError as e:
|
|
|
logger.error(f'Sandbox image build failed: {e}')
|
|
|
raise e
|
|
|
|
|
|
+ for log in build_logs:
|
|
|
+ if 'stream' in log:
|
|
|
+ print(log['stream'].strip())
|
|
|
+ elif 'error' in log:
|
|
|
+ logger.error(log['error'].strip())
|
|
|
+ else:
|
|
|
+ logger.info(str(log))
|
|
|
+
|
|
|
+ # 2. Re-tag the image with a more generic tag (as somewhat of "latest" tag)
|
|
|
+ logger.info(f'Image [{target_image_hash_name}] build finished.')
|
|
|
+ image = docker_client.images.get(target_image_hash_name)
|
|
|
+ image.tag(target_image_repo, target_image_tag)
|
|
|
+ logger.info(
|
|
|
+ f'Re-tagged image [{target_image_hash_name}] with more generic tag [{target_image_tag}]'
|
|
|
+ )
|
|
|
|
|
|
-def get_new_image_name(base_image: str, dev_mode: bool = False) -> str:
|
|
|
- if dev_mode:
|
|
|
- if 'od_runtime' not in base_image:
|
|
|
- raise ValueError(
|
|
|
- f'Base image {base_image} must be a valid od_runtime image to be used for dev mode.'
|
|
|
- )
|
|
|
- # remove the 'od_runtime' prefix from the base_image
|
|
|
- return base_image.replace('od_runtime', 'od_runtime_dev')
|
|
|
- elif 'od_runtime' in base_image:
|
|
|
- # if the base image is a valid od_runtime image, we will use it as is
|
|
|
- logger.info(f'Using existing od_runtime image [{base_image}]')
|
|
|
- return base_image
|
|
|
+ # check if the image is built successfully
|
|
|
+ image = docker_client.images.get(target_image_hash_name)
|
|
|
+ if image is None:
|
|
|
+ raise RuntimeError(
|
|
|
+ f'Build failed: Image [{target_image_repo}:{target_image_hash_tag}] not found'
|
|
|
+ )
|
|
|
+ logger.info(
|
|
|
+ f'Image [{target_image_repo}:{target_image_hash_tag}] (hash: [{target_image_tag}]) built successfully'
|
|
|
+ )
|
|
|
+ return target_image_hash_name
|
|
|
+
|
|
|
+
|
|
|
+def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
|
|
|
+ if RUNTIME_IMAGE_REPO in base_image:
|
|
|
+ logger.info(
|
|
|
+ f'The provided image [{base_image}] is a already a valid od_runtime image.\n'
|
|
|
+ f'Will try to reuse it as is.'
|
|
|
+ )
|
|
|
+ if ':' not in base_image:
|
|
|
+ base_image = base_image + ':latest'
|
|
|
+ repo, tag = base_image.split(':')
|
|
|
+ return repo, tag
|
|
|
else:
|
|
|
- prefix = 'od_runtime'
|
|
|
if ':' not in base_image:
|
|
|
base_image = base_image + ':latest'
|
|
|
[repo, tag] = base_image.split(':')
|
|
|
repo = repo.replace('/', '___')
|
|
|
-
|
|
|
od_version = _get_package_version()
|
|
|
- return f'{prefix}:od_v{od_version}_image_{repo}_tag_{tag}'
|
|
|
+ return RUNTIME_IMAGE_REPO, f'od_v{od_version}_image_{repo}_tag_{tag}'
|
|
|
|
|
|
|
|
|
def _check_image_exists(image_name: str, docker_client: docker.DockerClient) -> bool:
|
|
|
@@ -253,95 +236,123 @@ def _check_image_exists(image_name: str, docker_client: docker.DockerClient) ->
|
|
|
def build_runtime_image(
|
|
|
base_image: str,
|
|
|
docker_client: docker.DockerClient,
|
|
|
- update_source_code: bool = False,
|
|
|
- save_to_local_store: bool = False, # New parameter to control saving to local store
|
|
|
- extra_deps: str
|
|
|
- | None = None, # whether to install extra dependencies inside the image
|
|
|
+ extra_deps: str | None = None,
|
|
|
+ docker_build_folder: str | None = None,
|
|
|
+ dry_run: bool = False,
|
|
|
) -> str:
|
|
|
"""Build the runtime image for the OpenDevin runtime.
|
|
|
|
|
|
- This is only used for **eventstream runtime**.
|
|
|
+ See https://docs.all-hands.dev/modules/usage/runtime for more details.
|
|
|
"""
|
|
|
- new_image_name = get_new_image_name(base_image)
|
|
|
- if base_image == new_image_name:
|
|
|
- logger.info(
|
|
|
- f'Using existing od_runtime image [{base_image}]. Will NOT build a new image.'
|
|
|
+ runtime_image_repo, runtime_image_tag = get_runtime_image_repo_and_tag(base_image)
|
|
|
+
|
|
|
+ # Calculate the hash for the docker build folder (source code and Dockerfile)
|
|
|
+ with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
+ from_scratch_hash = prep_docker_build_folder(
|
|
|
+ temp_dir,
|
|
|
+ base_image=base_image,
|
|
|
+ skip_init=False,
|
|
|
+ extra_deps=extra_deps,
|
|
|
)
|
|
|
- else:
|
|
|
- logger.info(f'New image name: {new_image_name}')
|
|
|
|
|
|
- # Ensure new_image_name contains a colon
|
|
|
- if ':' not in new_image_name:
|
|
|
- raise ValueError(
|
|
|
- f'Invalid image name: {new_image_name}. Expected format "repository:tag".'
|
|
|
- )
|
|
|
+ # hash image name, if the hash matches, it means the image is already
|
|
|
+ # built from scratch with the *exact SAME source code* on the exact Dockerfile
|
|
|
+ hash_runtime_image_name = f'{runtime_image_repo}:{from_scratch_hash}'
|
|
|
|
|
|
- # Detect if the sandbox image is built
|
|
|
- image_exists = _check_image_exists(new_image_name, docker_client)
|
|
|
- if image_exists:
|
|
|
- logger.info(f'Image {new_image_name} exists')
|
|
|
- else:
|
|
|
- logger.info(f'Image {new_image_name} does not exist')
|
|
|
+ # non-hash generic image name, it could contains *similar* dependencies
|
|
|
+ # but *might* not exactly match the state of the source code.
|
|
|
+ # It resembles the "latest" tag in the docker image naming convention for
|
|
|
+ # a particular {repo}:{tag} pair (e.g., ubuntu:latest -> od_runtime:ubuntu_tag_latest)
|
|
|
+ # we will build from IT to save time if the `from_scratch_hash` is not found
|
|
|
+ generic_runtime_image_name = f'{runtime_image_repo}:{runtime_image_tag}'
|
|
|
|
|
|
- skip_init = False
|
|
|
- if image_exists and not update_source_code:
|
|
|
- # If (1) Image exists & we are not updating the source code, we can reuse the existing production image
|
|
|
- logger.info('No image build done (not updating source code)')
|
|
|
- return new_image_name
|
|
|
+ # 1. If the image exists with the same hash, we will reuse it as is
|
|
|
+ if _check_image_exists(hash_runtime_image_name, docker_client):
|
|
|
+ logger.info(
|
|
|
+ f'Image [{hash_runtime_image_name}] exists with matched hash for Docker build folder.\n'
|
|
|
+ 'Will reuse it as is.'
|
|
|
+ )
|
|
|
+ return hash_runtime_image_name
|
|
|
|
|
|
- elif image_exists and update_source_code:
|
|
|
- # If (2) Image exists & we plan to update the source code (in dev mode), we need to rebuild the image
|
|
|
- # and give it a special name
|
|
|
- # e.g., od_runtime:ubuntu_tag_latest -> od_runtime_dev:ubuntu_tag_latest
|
|
|
- logger.info('Image exists, but updating source code requested')
|
|
|
- base_image = new_image_name
|
|
|
- new_image_name = get_new_image_name(base_image, dev_mode=True)
|
|
|
+ # 2. If the exact hash is not found, we will FIRST try to re-build it
|
|
|
+ # by leveraging the non-hash `generic_runtime_image_name` to save some time
|
|
|
+ # from re-building the dependencies (e.g., poetry install, apt install)
|
|
|
+ elif _check_image_exists(generic_runtime_image_name, docker_client):
|
|
|
+ logger.info(
|
|
|
+ f'Cannot find matched hash for image [{hash_runtime_image_name}]\n'
|
|
|
+ f'Will try to re-build it from latest [{generic_runtime_image_name}] image to potentially save '
|
|
|
+ f'time for dependencies installation.\n'
|
|
|
+ )
|
|
|
|
|
|
- skip_init = True # since we only need to update the source code
|
|
|
+ cur_docker_build_folder = docker_build_folder or tempfile.mkdtemp()
|
|
|
+ _skip_init_hash = prep_docker_build_folder(
|
|
|
+ cur_docker_build_folder,
|
|
|
+ # we want to use the existing generic image as base
|
|
|
+ # so that we can leverage existing dependencies already installed in the image
|
|
|
+ base_image=generic_runtime_image_name,
|
|
|
+ skip_init=True, # skip init since we are re-using the existing image
|
|
|
+ extra_deps=extra_deps,
|
|
|
+ )
|
|
|
+ assert (
|
|
|
+ _skip_init_hash != from_scratch_hash
|
|
|
+ ), f'The skip_init hash [{_skip_init_hash}] should not match the existing hash [{from_scratch_hash}]'
|
|
|
+
|
|
|
+ if not dry_run:
|
|
|
+ _build_sandbox_image(
|
|
|
+ docker_folder=cur_docker_build_folder,
|
|
|
+ docker_client=docker_client,
|
|
|
+ target_image_repo=runtime_image_repo,
|
|
|
+ # NOTE: WE ALWAYS use the "from_scratch_hash" tag for the target image
|
|
|
+ # otherwise, even if the source code is exactly the same, the image *might* be re-built
|
|
|
+ # because the same source code will generate different hash when skip_init=True/False
|
|
|
+ # since the Dockerfile is slightly different
|
|
|
+ target_image_hash_tag=from_scratch_hash,
|
|
|
+ target_image_tag=runtime_image_tag,
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ logger.info(
|
|
|
+ f'Dry run: Skipping image build for [{generic_runtime_image_name}]'
|
|
|
+ )
|
|
|
+ if docker_build_folder is None:
|
|
|
+ shutil.rmtree(cur_docker_build_folder)
|
|
|
|
|
|
+ # 3. If the image is not found AND we cannot re-use the non-hash latest relavant image,
|
|
|
+ # we will build it completely from scratch
|
|
|
else:
|
|
|
- # If (3) Image does not exist, we need to build it from scratch
|
|
|
- # e.g., ubuntu:latest -> od_runtime:ubuntu_tag_latest
|
|
|
- # This snippet would allow to load from archive:
|
|
|
- # tar_path = f'{new_image_name.replace(":", "_")}.tar'
|
|
|
- # if os.path.exists(tar_path):
|
|
|
- # logger.info(f'Loading image from {tar_path}')
|
|
|
- # load_command = ['docker', 'load', '-i', tar_path]
|
|
|
- # subprocess.run(load_command, check=True)
|
|
|
- # logger.info(f'Image {new_image_name} loaded from {tar_path}')
|
|
|
- # return new_image_name
|
|
|
- skip_init = False
|
|
|
-
|
|
|
- if not skip_init:
|
|
|
- logger.info(f'Building image [{new_image_name}] from scratch')
|
|
|
-
|
|
|
- new_image_name = _build_sandbox_image(
|
|
|
- base_image,
|
|
|
- new_image_name,
|
|
|
- docker_client,
|
|
|
- skip_init=skip_init,
|
|
|
- extra_deps=extra_deps,
|
|
|
- )
|
|
|
-
|
|
|
- # Only for development: allow to save image as archive:
|
|
|
- if not image_exists and save_to_local_store:
|
|
|
- tar_path = f'{new_image_name.replace(":", "_")}.tar'
|
|
|
- save_command = ['docker', 'save', '-o', tar_path, new_image_name]
|
|
|
- subprocess.run(save_command, check=True)
|
|
|
- logger.info(f'Image saved to {tar_path}')
|
|
|
+ cur_docker_build_folder = docker_build_folder or tempfile.mkdtemp()
|
|
|
+ _new_from_scratch_hash = prep_docker_build_folder(
|
|
|
+ cur_docker_build_folder,
|
|
|
+ base_image,
|
|
|
+ skip_init=False,
|
|
|
+ extra_deps=extra_deps,
|
|
|
+ )
|
|
|
+ assert (
|
|
|
+ _new_from_scratch_hash == from_scratch_hash
|
|
|
+ ), f'The new from scratch hash [{_new_from_scratch_hash}] does not match the existing hash [{from_scratch_hash}]'
|
|
|
+
|
|
|
+ if not dry_run:
|
|
|
+ _build_sandbox_image(
|
|
|
+ docker_folder=cur_docker_build_folder,
|
|
|
+ docker_client=docker_client,
|
|
|
+ target_image_repo=runtime_image_repo,
|
|
|
+ # NOTE: WE ALWAYS use the "from_scratch_hash" tag for the target image
|
|
|
+ target_image_hash_tag=from_scratch_hash,
|
|
|
+ target_image_tag=runtime_image_tag,
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ logger.info(
|
|
|
+ f'Dry run: Skipping image build for [{generic_runtime_image_name}]'
|
|
|
+ )
|
|
|
|
|
|
- load_command = ['docker', 'load', '-i', tar_path]
|
|
|
- subprocess.run(load_command, check=True)
|
|
|
- logger.info(f'Image {new_image_name} loaded back into Docker from {tar_path}')
|
|
|
+ if docker_build_folder is None:
|
|
|
+ shutil.rmtree(cur_docker_build_folder)
|
|
|
|
|
|
- return new_image_name
|
|
|
+ return f'{runtime_image_repo}:{from_scratch_hash}'
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
parser = argparse.ArgumentParser()
|
|
|
parser.add_argument('--base_image', type=str, default='ubuntu:22.04')
|
|
|
- parser.add_argument('--update_source_code', action='store_true')
|
|
|
- parser.add_argument('--save_to_local_store', action='store_true')
|
|
|
parser.add_argument('--build_folder', type=str, default=None)
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
@@ -353,31 +364,40 @@ if __name__ == '__main__':
|
|
|
logger.info(
|
|
|
f'Will prepare a build folder by copying the source code and generating the Dockerfile: {build_folder}'
|
|
|
)
|
|
|
- new_image_path = get_new_image_name(args.base_image)
|
|
|
- dir_hash = prep_docker_build_folder(
|
|
|
- build_folder, args.base_image, skip_init=args.update_source_code
|
|
|
+ runtime_image_repo, runtime_image_tag = get_runtime_image_repo_and_tag(
|
|
|
+ args.base_image
|
|
|
)
|
|
|
- new_image_name, new_image_tag = new_image_path.split(':')
|
|
|
+ with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
+ runtime_image_hash_name = build_runtime_image(
|
|
|
+ args.base_image,
|
|
|
+ docker_client=docker.from_env(),
|
|
|
+ docker_build_folder=temp_dir,
|
|
|
+ dry_run=True,
|
|
|
+ )
|
|
|
+ _runtime_image_repo, runtime_image_hash_tag = runtime_image_hash_name.split(
|
|
|
+ ':'
|
|
|
+ )
|
|
|
+ # Move contents of temp_dir to build_folder
|
|
|
+ shutil.copytree(temp_dir, build_folder, dirs_exist_ok=True)
|
|
|
+ logger.info(
|
|
|
+ f'Build folder [{build_folder}] is ready: {os.listdir(build_folder)}'
|
|
|
+ )
|
|
|
+
|
|
|
with open(os.path.join(build_folder, 'config.sh'), 'a') as file:
|
|
|
file.write(
|
|
|
(
|
|
|
f'\n'
|
|
|
- f'DOCKER_IMAGE={new_image_name}\n'
|
|
|
- f'DOCKER_IMAGE_TAG={new_image_tag}\n'
|
|
|
- f'DOCKER_IMAGE_HASH_TAG={dir_hash}\n'
|
|
|
+ f'DOCKER_IMAGE={runtime_image_repo}\n'
|
|
|
+ f'DOCKER_IMAGE_TAG={runtime_image_tag}\n'
|
|
|
+ f'DOCKER_IMAGE_HASH_TAG={runtime_image_hash_tag}\n'
|
|
|
)
|
|
|
)
|
|
|
logger.info(
|
|
|
- f'`config.sh` is updated with the new image name [{new_image_name}] and tag [{new_image_tag}]'
|
|
|
+ f'`config.sh` is updated with the new image name [{runtime_image_repo}] and tag [{runtime_image_tag}, {runtime_image_hash_tag}]'
|
|
|
)
|
|
|
logger.info(f'Dockerfile and source distribution are ready in {build_folder}')
|
|
|
else:
|
|
|
logger.info('Building image in a temporary folder')
|
|
|
client = docker.from_env()
|
|
|
- image_name = build_runtime_image(
|
|
|
- args.base_image,
|
|
|
- client,
|
|
|
- update_source_code=args.update_source_code,
|
|
|
- save_to_local_store=args.save_to_local_store,
|
|
|
- )
|
|
|
+ image_name = build_runtime_image(args.base_image, client)
|
|
|
print(f'\nBUILT Image: {image_name}\n')
|