From 1a607e91d0856fd5dc0b132e9748826b0cc7566d Mon Sep 17 00:00:00 2001 From: Cormac McCarthy Date: Thu, 4 May 2023 17:10:13 -0700 Subject: [PATCH] Use buildpacks for 'az containerapp up --source' no Dockerfile scenario --- src/containerapp/.gitignore | 3 + .../azext_containerapp/_params.py | 2 +- .../azext_containerapp/_up_utils.py | 73 ++++++++++++++++++- src/containerapp/azext_containerapp/_utils.py | 56 ++++++++++++++ src/containerapp/setup.py | 38 +++++++++- 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/containerapp/.gitignore diff --git a/src/containerapp/.gitignore b/src/containerapp/.gitignore new file mode 100644 index 00000000000..d79675e9460 --- /dev/null +++ b/src/containerapp/.gitignore @@ -0,0 +1,3 @@ +# Temporary folders for shared libraries +azext_containerapp/bin/ +azext_containerapp/bin/* \ No newline at end of file diff --git a/src/containerapp/azext_containerapp/_params.py b/src/containerapp/azext_containerapp/_params.py index 0fe3852be6b..e777212724d 100644 --- a/src/containerapp/azext_containerapp/_params.py +++ b/src/containerapp/azext_containerapp/_params.py @@ -321,7 +321,7 @@ def load_arguments(self, _): c.argument('name', configured_default='name', id_part=None) c.argument('managed_env', configured_default='managed_env') c.argument('registry_server', configured_default='registry_server') - c.argument('source', help='Local directory path containing the application source and Dockerfile for building the container image. Preview: If no Dockerfile is present, a container image is generated using Oryx. See the supported Oryx runtimes here: https://github.com/microsoft/Oryx/blob/main/doc/supportedRuntimeVersions.md.') + c.argument('source', help='Local directory path containing the application source and Dockerfile for building the container image. Preview: If no Dockerfile is present, a container image is generated using buildpacks. If Docker is not running or buildpacks cannot be used, Oryx will be used to generate the image. See the supported Oryx runtimes here: https://github.com/microsoft/Oryx/blob/main/doc/supportedRuntimeVersions.md.') c.argument('image', options_list=['--image', '-i'], help="Container image, e.g. publisher/image-name:tag.") c.argument('browse', help='Open the app in a web browser after creation and deployment, if possible.') c.argument('workload_profile_name', options_list=['--workload-profile-name', '-w'], help='The friendly name for the workload profile') diff --git a/src/containerapp/azext_containerapp/_up_utils.py b/src/containerapp/azext_containerapp/_up_utils.py index 11034fbcdb0..bb2da6db397 100644 --- a/src/containerapp/azext_containerapp/_up_utils.py +++ b/src/containerapp/azext_containerapp/_up_utils.py @@ -8,6 +8,7 @@ from tempfile import NamedTemporaryFile from urllib.parse import urlparse import requests +import subprocess from azure.cli.core.azclierror import ( RequiredArgumentMissingError, @@ -48,7 +49,9 @@ register_provider_if_needed, validate_environment_location, list_environment_locations, - format_location + format_location, + is_docker_running, + get_pack_exec_path ) from ._constants import (MAXIMUM_SECRET_LENGTH, @@ -354,7 +357,60 @@ def create_acr(self): self.cmd.cli_ctx, registry_name ) - def build_container_from_source(self, image_name, source): + def build_container_from_source_with_buildpack(self, image_name, source): + # Ensure that Docker is running + if not is_docker_running(): + raise CLIError("Docker is not running. Please start Docker and try again.") + + # Ensure that the pack CLI is installed + pack_exec_path = get_pack_exec_path() + if pack_exec_path == "": + raise CLIError("The pack CLI could not be installed.") + + logger.info("Docker is running and pack CLI is installed; attempting to use buildpacks to build container image...") + + registry_name = self.registry_server.lower() + image_name = f"{registry_name}/{image_name}" + builder_image_name="mcr.microsoft.com/oryx/builder:builder-dotnet-7.0" + + # Ensure that the builder is trusted + command = [pack_exec_path, 'config', 'default-builder', builder_image_name] + logger.debug(f"Calling '{' '.join(command)}'") + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + if process.returncode != 0: + raise CLIError(f"Error thrown when running 'pack config': {stderr.decode('utf-8')}") + logger.debug(f"Successfully set the default builder to {builder_image_name}.") + except Exception as ex: + raise CLIError(f"Unable to run 'pack build' command to produce runnable application image: {ex}") + + # Run 'pack build' to produce a runnable application image for the Container App + command = [pack_exec_path, 'build', image_name, '--builder', builder_image_name, '--path', source] + logger.debug(f"Calling '{' '.join(command)}'") + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + if process.returncode != 0: + raise CLIError(f"Error thrown when running 'pack build': {stderr.decode('utf-8')}") + logger.debug(f"Successfully built image {image_name} using buildpacks.") + except Exception as ex: + raise CLIError(f"Unable to run 'pack build' command to produce runnable application image: {ex}") + + # Run 'docker push' to push the image to the ACR + command = ['docker', 'push', image_name] + logger.debug(f"Calling '{' '.join(command)}'") + logger.warning(f"Built image {image_name} locally using buildpacks, attempting to push to registry...") + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + if process.returncode != 0: + raise CLIError(f"Error thrown when running 'docker push': {stderr.decode('utf-8')}") + logger.debug(f"Successfully pushed image {image_name} to ACR.") + except Exception as ex: + raise CLIError(f"Unable to run 'docker push' command to push image to ACR: {ex}") + + def build_container_from_source_with_acr_task(self, image_name, source): from azure.cli.command_modules.acr.task import acr_task_create, acr_task_run from azure.cli.command_modules.acr._client_factory import cf_acr_tasks, cf_acr_runs from azure.cli.core.profiles import ResourceType @@ -414,7 +470,18 @@ def run_acr_build(self, dockerfile, source, quiet=False, build_from_source=False if build_from_source: # TODO should we prompt for confirmation here? logger.warning("No dockerfile detected. Attempting to build a container directly from the provided source...") - self.build_container_from_source(image_name, source) + + try: + # First try to build source using buildpacks + logger.warning("Attempting to build image using buildpacks...") + self.build_container_from_source_with_buildpack(image_name, source) + return + except CLIError as e: + logger.warning(f"Unable to use buildpacks to build source: {e}\n Falling back to ACR Task...") + + # If we're unable to use the buildpack, build source using an ACR Task + logger.warning("Attempting to build image using ACR Task...") + self.build_container_from_source_with_acr_task(image_name, source) else: queue_acr_build( self.cmd, diff --git a/src/containerapp/azext_containerapp/_utils.py b/src/containerapp/azext_containerapp/_utils.py index 8bcc296a234..d162196556f 100644 --- a/src/containerapp/azext_containerapp/_utils.py +++ b/src/containerapp/azext_containerapp/_utils.py @@ -7,6 +7,9 @@ import time import json import platform +import docker +import os +import requests from urllib.parse import urlparse from datetime import datetime @@ -1714,3 +1717,56 @@ def format_location(location=None): if location: return location.lower().replace(" ", "").replace("(", "").replace(")", "") return location + + +def is_docker_running(): + # check to see if docker is running + client = None + out = True + try: + client = docker.from_env() + # need any command that will show the docker daemon is not running + client.containers.list() + except docker.errors.DockerException: + out = False + finally: + if client: + client.close() + return out + + +def get_pack_exec_path(): + try: + dir_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "azext_containerapp") + bin_folder = dir_path + "/bin" + if not os.path.exists(bin_folder): + os.makedirs(bin_folder) + + exec_name = "" + host_os = platform.system() + if host_os == "Windows": + exec_name = "pack-v0.29.0-windows.exe" + elif host_os == "Linux": + exec_name = "pack-v0.29.0-linux" + elif host_os == "Darwin": + exec_name = "pack-v0.29.0-macos" + else: + raise Exception(f"Unsupported host OS: {host_os}") + + exec_path = os.path.join(bin_folder, exec_name) + if os.path.exists(exec_path): + return exec_path + + # Attempt to install the pack CLI + url = f"https://cormteststorage.blob.core.windows.net/pack/{exec_name}" + r = requests.get(url) + with open(exec_path, "wb") as f: + f.write(r.content) + print(f"Successfully installed pack CLI to {exec_path}\n") + return exec_path + + except Exception as e: + # Swallow any exceptions thrown when attempting to install pack CLI + print(f"Failed to install pack CLI: {e}\n") + + return "" diff --git a/src/containerapp/setup.py b/src/containerapp/setup.py index 4b4409cc514..f4d46d94f77 100644 --- a/src/containerapp/setup.py +++ b/src/containerapp/setup.py @@ -8,6 +8,11 @@ from codecs import open from setuptools import setup, find_packages + +import os +import platform +import requests + try: from azure_bdist_wheel import cmdclass except ImportError: @@ -37,9 +42,40 @@ # TODO: Add any additional SDK dependencies here DEPENDENCIES = [ - 'pycomposefile>=0.0.29' + 'pycomposefile>=0.0.29', + 'docker' ] +# Install pack CLI to build runnable application images from source +try: + dir_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "azext_containerapp") + bin_folder = dir_path + "/bin" + if not os.path.exists(bin_folder): + os.makedirs(bin_folder) + + exec_name = "" + host_os = platform.system() + if host_os == "Windows": + exec_name = "pack-v0.29.0-windows.exe" + elif host_os == "Linux": + exec_name = "pack-v0.29.0-linux" + elif host_os == "Darwin": + exec_name = "pack-v0.29.0-macos" + else: + raise Exception(f"Unsupported host OS: {host_os}") + + exec_path = os.path.join(bin_folder, exec_name) + if not os.path.exists(exec_path): + url = f"https://cormteststorage.blob.core.windows.net/pack/{exec_name}" + r = requests.get(url) + with open(exec_path, "wb") as f: + f.write(r.content) + print(f"Successfully installed pack CLI to {exec_path}\n") + +except Exception as e: + # Swallow any exceptions thrown when attempting to install pack CLI + print(f"Failed to install pack CLI: {e}\n") + with open('README.rst', 'r', encoding='utf-8') as f: README = f.read() with open('HISTORY.rst', 'r', encoding='utf-8') as f: