import os import re import docker import logging import datetime import requests from .version import BlenderVersion from .dependency import BlenderDependency from pathlib import Path from docker.errors import BuildError, APIError, ContainerError, ImageNotFound _ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]') def _clean_log_line(text: str) -> str: """Strip ANSI escape codes and keep only the last update of a \r-rewritten line.""" text = _ANSI_ESCAPE_RE.sub('', text) return text.split('\r')[-1].strip() class BlenderDocker: def __init__(self, blender: BlenderVersion, severity: int = logging.WARNING): self._blender_version = blender self._docker_client = docker.from_env() self.login() self._docker_image = None self._repository = 'stilobique/blender' self._docker_image_name = self._blender_version.format_to_tag(self._repository) self._docker_file = Path(os.getcwd(), 'Dockerfile') if not self._docker_file.exists(): print(f'Dockerfile not found: {self._docker_file}') exit(1) self._packages = BlenderDependency(blender) self._args = { 'b3d_vs_major': f'{self._blender_version.major}', 'b3d_vs_minor': f'{self._blender_version.minor}.{self._blender_version.hotfix}', 'b3d_dependency': self._packages.__str__(), 'python_version': self._get_required_python_version(), } self._severity = severity def _get_required_python_version(self) -> str: """ Fetch the exact Python version (major.minor) Blender's own CMake pins for this release, so the build stage's base image can match it instead of drifting with whatever a floating `python` tag currently resolves to. """ url = (f'https://projects.blender.org/api/v1/repos/blender/blender/raw/' f'build_files/cmake/Modules/FindPythonLibsUnix.cmake?ref=v{self._blender_version.tag_name()}') r = requests.get(url) r.raise_for_status() match = re.search(r'set\(_PYTHON_VERSION_SUPPORTED\s+([0-9]+\.[0-9]+)\)', r.text) if not match: print(f'Could not determine required Python version from {url}') exit(1) return match.group(1) def _get_logger(self, name: str) -> logging.Logger: """ Private Method to set a logger for a specific method set (by his name) :param name: Make the logger file name. :return: """ log_dir = Path('logs') log_dir.mkdir(parents=True, exist_ok=True) logger = logging.getLogger(f'blender_docker.{name}') logger.setLevel(self._severity) logger.propagate = False if not logger.handlers: handler = logging.FileHandler(log_dir / f'{name}.log', encoding='utf-8') handler.setFormatter(logging.Formatter( '%(asctime)s | [%(levelname)s] %(message)s' )) logger.addHandler(handler) return logger def login(self): """Login to the Docker Hub""" try: print('Login to Hub Docker.') self._docker_client.login( username=os.environ['DOCKER_HUB_USER'], password=os.environ['DOCKER_HUB_PASSWORD'] ) except APIError as e: print(f'Login failed with error: {e}') exit(1) def build(self): """Build the Blender Docker Image""" time = datetime.datetime.now() logger = self._get_logger(f'build-{time.strftime("%Y-%m-%d-%H-%M")}') collected_logs = [] try: print('Building Blender Docker Image') logger.info('Building Blender Docker Image') logger.info(f'\tBuilds withs args: {self._args}') logger.info(f'\tTag generate: {self._docker_image_name}') # Use the low-level API: images.build() only returns once the whole # build is finished, so it gives no live progress. api.build() with # decode=True streams each step (including cache hits) as it happens. build_logs = self._docker_client.api.build( path=os.getcwd(), # nocache=True, buildargs=self._args, tag=self._docker_image_name, rm=True, forcerm=True, decode=True, ) for chunk in build_logs: collected_logs.append(chunk) if 'stream' in chunk: for raw_line in chunk['stream'].split('\n'): line = _clean_log_line(raw_line) if not line: continue if 'Using cache' in line: line = f'[CACHE] {line}' logger.info(line) elif 'status' in chunk: status = _clean_log_line(chunk['status']) progress = _clean_log_line(chunk.get('progress', '')) line = f"{status} {chunk.get('id', '')} {progress}".strip() if 'Already exists' in status: line = f'[CACHE] {line}' logger.info(line) elif 'error' in chunk: logger.error(chunk['error']) raise BuildError(chunk['error'], collected_logs) self._docker_image = self._docker_client.images.get(self._docker_image_name) except BuildError as e: logger.exception(f'Build docker image failed: {e}') raise @staticmethod def tag(self): print('\tSet Tags version and/or Latest') pass def test(self) -> bool: """ This method request an image (call the method build before) and try to execute the blender inside the container to check if the program can be correctly executed. Based on `docker run --rm stilobique/blender:5.1.2 ./blender --version` :return: True if no issue are returned """ logger = self._get_logger('test') container_name = f"Blender-Docker-Testing-{self._blender_version.tag_name_slugify()}" command = ['./blender', '--version'] container = None try: print(f'Testing your Blender Docker image {self._docker_image_name}') logger.info(f'Testing image "{self._docker_image_name}" in container "{container_name}" ' f'with command: {" ".join(command)}') # auto_remove is intentionally NOT used: Docker can remove the container # right after it exits, racing with the logs() call below and turning a # real container-side error into an unrelated 409 "dead or marked for # removal" API error. We remove the container ourselves once logs are read. container = self._docker_client.containers.run( name=container_name, image=self._docker_image_name, command=command, detach=True, stdout=True, stderr=True, ) result = container.wait() exit_code = result.get('StatusCode') container_logs = container.logs(stdout=True, stderr=True).decode('utf-8', errors='replace').strip() for raw_line in container_logs.split('\n'): line = _clean_log_line(raw_line) if line: logger.info(f'[container] {line}') if exit_code == 0: logger.info(f'Test succeeded (exit code {exit_code}) for image "{self._docker_image_name}".') return True logger.error(f'Test failed (exit code {exit_code}) for image "{self._docker_image_name}".') print(f'Testing your Blender Docker image {self._docker_image_name} failed.') return False except ContainerError as e: logger.exception(f'Container error while testing "{self._docker_image_name}": {e}') print(f'Container error: {e}') exit(1) except ImageNotFound as e: logger.exception(f'Image "{self._docker_image_name}" not found: {e}') print(f'Image not found: {e}') exit(1) except APIError as e: logger.exception(f'API error while testing "{self._docker_image_name}": {e}') print(f'API error: {e}') exit(1) finally: if container is not None: try: container.remove(force=True) except APIError: pass def push(self): """Push the Blender Docker Image""" time = datetime.datetime.now() logger = self._get_logger(f'push') collected_logs = [] try: print(f'Pushing Blender Docker image {self._docker_image_name}') logger.info(f'Pushing Blender Docker Image') logger.info(f'\tTag pushed: {self._docker_image_name}') # Use the low-level API: images.push() only returns once the whole # push is finished, so it gives no live progress. api.push() with # decode=True streams each layer's progress as it happens. push_logs = self._docker_client.api.push( repository=self._repository, tag=self._blender_version.tag_name(), stream=True, decode=True, ) for chunk in push_logs: collected_logs.append(chunk) if 'status' in chunk: status = _clean_log_line(chunk['status']) progress = _clean_log_line(chunk.get('progress', '')) line = f"{status} {chunk.get('id', '')} {progress}".strip() if 'Layer already exists' in status: line = f'[CACHE] {line}' logger.info(line) elif 'error' in chunk: logger.error(chunk['error']) raise APIError(chunk['error']) logger.info(f'Push succeeded for image "{self._docker_image_name}".') except APIError as e: logger.exception(f'Push docker image failed: {e}') raise