From 81dfce05405fe11ddc90fb1f2a8aa974af6d8851 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Mon, 20 Apr 2026 14:49:34 -0700 Subject: [PATCH 01/34] Add payload accessibility --- constructor/shar.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/constructor/shar.py b/constructor/shar.py index a0b631267..7429ef49f 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -19,6 +19,7 @@ from contextlib import nullcontext from io import BytesIO from os.path import basename, dirname, getsize, isdir, join, relpath +from pathlib import Path from .construct import ns_platform from .jinja import render_template @@ -182,11 +183,6 @@ def create(info, verbose=False): join(tmp_dir, "envs", env_name, "conda-meta", "history"), f"envs/{env_name}/conda-meta/history", ) - if os.path.exists(join(tmp_dir, "envs", env_name, "conda-meta", "frozen")): - post_t.add( - join(tmp_dir, "envs", env_name, "conda-meta", "frozen"), - f"envs/{env_name}/conda-meta/frozen", - ) extra_files = copy_extra_files(info.get("extra_files", []), tmp_dir) for path in extra_files: @@ -240,6 +236,33 @@ def create(info, verbose=False): break fo.write(chunk) + # Save payload for Docker builds if dockerfile output is requested + if "dockerfile" in str(info.get("build_outputs", [])) or info.get("installer_type") == "docker": + output_dir = Path(info["_output_dir"]) + payload_dir = output_dir / "docker" / "_payload" + payload_dir.mkdir(parents=True, exist_ok=True) + + # Save the main payload + payload_dest = payload_dir / "_conda" + shutil.copy(tarball, payload_dest) + logger.info(f"Saved conda payload: {payload_dest}") + + # Save preconda + preconda_dest = payload_dir / "preconda.tar.bz2" + shutil.copy(preconda_tarball, preconda_dest) # preconda_tarball already exists at line 94 + logger.info(f"Saved preconda: {preconda_dest}") + + # Save postconda + postconda_dest = payload_dir / "postconda.tar.bz2" + shutil.copy(postconda_tarball, postconda_dest) # postconda_tarball exists at line 95 + logger.info(f"Saved postconda: {postconda_dest}") + + # print("TMP DIR:", tmp_dir) + # print("PRECONDA:", preconda_tarball) + # print("POSTCONDA:", postconda_tarball) + # print("PAYLOAD TAR:", tarball) + # print("OUTPATH:", shar_path) + os.unlink(tarball) os.chmod(shar_path, 0o755) if not info.get("_debug"): From 749abd4a63db0878a6dadbcf53b0ee6e37006610 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 22 Apr 2026 12:24:07 -0700 Subject: [PATCH 02/34] First pass --- constructor/_schema.py | 22 ++++ constructor/docker_build.py | 179 +++++++++++++++++++++++++++ constructor/dockerfile_template.tmpl | 47 +++++++ constructor/main.py | 6 +- 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 constructor/docker_build.py create mode 100644 constructor/dockerfile_template.tmpl diff --git a/constructor/_schema.py b/constructor/_schema.py index 87945ad03..52681f463 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -42,6 +42,7 @@ class InstallerTypes(StrEnum): EXE = "exe" PKG = "pkg" SH = "sh" + DOCKER = "docker" class PkgDomains(StrEnum): @@ -403,6 +404,7 @@ class ConstructorConfiguration(BaseModel): - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS + - `docker`: a Dockerfile that replicates the installation environment The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -853,6 +855,26 @@ class ConstructorConfiguration(BaseModel): message: "This base environment is frozen and cannot be modified." ``` """ + docker_base_image: NonEmptyStr | None = None + """ + Base image to use for docker builds when `installer_type` includes `docker`. + Defaults to `debian:13.4-slim`. + """ + docker_base_image_sha: NonEmptyStr | None = None + """ + If `docker_base_image` is provided, you can also provide a SHA256 hash of + the image to ensure the integrity of the base image used for the build. + Otherwise, the base image defaults to `latest`. + """ + docker_tag: NonEmptyStr | None = None + """ + Tag to use for the built docker image when `installer_type` includes `docker`. + If not provided, it will default to `:`. + """ + docker_labels: dict[NonEmptyStr, NonEmptyStr] = {} + """ + Labels to add to the built docker image when `installer_type` includes `docker`. + """ def fix_descriptions(obj): diff --git a/constructor/docker_build.py b/constructor/docker_build.py new file mode 100644 index 000000000..9b3626249 --- /dev/null +++ b/constructor/docker_build.py @@ -0,0 +1,179 @@ +import logging +import shutil +import subprocess +from pathlib import Path +from jinja2 import Template + +from . import __version__ + +logger = logging.getLogger(__name__) + +DEFAULT_BASE_IMAGE = "debian:13.4-slim" +DEFAULT_BASE_IMAGE_SHA = "4ffb3a1511099754cddc70eb1b12e50ffdb67619aa0ab6c13fcd800a78ef7c7a" +TEMPLATE_PATH = Path(__file__).parent / "dockerfile_template.tmpl" + +ARCH_MAP = { + "64": "amd64", + "arm64": "arm64", + "aarch64": "arm64", +} + +def prepare_docker_context(info: dict) -> Path: + """Copy the .sh installer into the Docker build directory. + + Parameters + ---------- + info: dict + Constructor installer info dict. Must contain ``_outpath`` and ``_output_dir`` pointing to the built .sh installer and output directory respectively. + + Returns + ------- + Path + Path to the Docker build directory (``<_output_dir>/docker``). + """ + docker_dir = Path(info["_output_dir"]) / "docker" + docker_dir.mkdir(parents=True, exist_ok=True) + + installer_path = Path(info["_outpath"]) + if not installer_path.exists(): + raise RuntimeError( + f"Expected .sh installer not found: {installer_path}\n" + ) + + shutil.copy(installer_path, docker_dir / installer_path.name) + logger.info("Copied installer to Docker directory: %s", docker_dir / installer_path.name) + + return docker_dir + + +def generate_dockerfile(info: dict, docker_dir: Path) -> Path: + """ + Render the Dockerfile template and write it to `/Dockerfile`. + + Parameters + ---------- + info: dict + Constructor installer info dict. + docker_dir: Path + Path to the Docker build directory (``<_output_dir>/docker``) returned by prepare_docker_context(). + + Returns + ------- + Path + Path to the generated Dockerfile. ``/Dockerfile``. + """ + docker_template = Template(TEMPLATE_PATH.read_text()) + + docker_base_image = info.get("docker_base_image", DEFAULT_BASE_IMAGE) + docker_base_image_sha = ":latest" if not DEFAULT_BASE_IMAGE_SHA else f"@sha256:{DEFAULT_BASE_IMAGE_SHA}" + docker_base_image_sha = info.get("docker_base_image_sha", docker_base_image_sha) + + rendered_dockerfile = docker_template.render( + constructor_version=__version__, + base_image=f"{docker_base_image}{docker_base_image_sha}", + default_prefix=info.get("default_prefix", "/opt/conda"), + installer_filename=Path(info["_outpath"]).name, + name=info["name"], + version=info["version"], + labels=info.get("docker_labels", {}), + ) + + dockerfile_path = docker_dir / "Dockerfile" + dockerfile_path.write_text(rendered_dockerfile) + logger.info("Dockerfile written to: '%s'", dockerfile_path) + return dockerfile_path + + +def build_image(info: dict, docker_dir: Path) -> None: + """Optionally build the docker image from the generated Dockerfile. + + Parameters + ---------- + info: dict + Constructor installer info dict. + docker_dir: Path + Path to the Docker directory containing the Dockerfile. + + """ + if shutil.which("docker") is None: + raise RuntimeError( + "Building a Docker image requires the 'docker' CLI tool to be installed and available in PATH. " + "Install Docker Desktop or Docker Engine to proceed, or " + "use `installer_type: docker # [linux]` in construct.yaml to " + "generate the Dockerfile without building the image." + ) + + osname, arch = info["_platform"].split("-") + + if osname == "linux": + docker_cmd = ["docker", "build"] + else: + result = subprocess.run(["docker", "buildx", "version"], capture_output=True) + if result.returncode != 0: + raise RuntimeError( + "Building a Docker image for non-Linux platforms requires 'docker-buildx'. " + "Install docker-buildx and try again, or run the build on a Linux platform. " + "Alternatively, use `installer_type: docker # [linux]` in construct.yaml to " + "generate the Dockerfile without building the image." + ) + docker_arch = ARCH_MAP.get(arch) + if docker_arch is None: + raise RuntimeError( + f"Unsupported architecture for Docker build: {arch}\n" + f"Supported architectures: {', '.join(ARCH_MAP)}" + ) + docker_cmd = ["docker", "buildx", "build", "--platform", f"linux/{docker_arch}", "--load"] + + image_name = info.get("docker_image_name", info["name"].lower()) + image_version = info.get("docker_image_version", info["version"].split("-")[0]) + tag = f"{image_name}:{image_version}" + + cmd = [*docker_cmd, "-t", tag, str(docker_dir)] + + logger.info("Building Docker image: '%s'", tag) + subprocess.run(cmd, check=True) + logger.info("Docker image built: '%s'", tag) + +def cleanup(info: dict, docker_dir: Path) -> None: + """Remove the Docker build context directory after building. + + Parameters + ---------- + info: dict + Constructor installer info dict. + docker_dir: Path + Path to the Docker directory containing the Dockerfile. + + """ + installer_path = Path(info["_outpath"]) + + installer_path.unlink(missing_ok=True) + docker_dir.joinpath(installer_path.name).unlink(missing_ok=True) + logger.info("Removing installers from paths: %s, %s", installer_path, docker_dir.joinpath(installer_path.name)) + + # TODO: Add option for agressive cleanup which would remove dockerfile if building is enabled. + # shutil.rmtree(docker_dir) + # logger.info("Cleaned up Docker build directory: '%s'", docker_dir) + + +def create(info: dict, verbose: bool = False) -> None: + """Build a Docker output + + Parameters + ---------- + info: dict + Constructor installer info dict. + verbose: bool, optional + If ``True``, enables verbose logging. + Defaults to ``False``. + + """ + docker_dir = prepare_docker_context(info) + generate_dockerfile(info, docker_dir) + + if info.get("docker_build"): + build_image(info, docker_dir) + + cleanup(info, docker_dir) + + logger.info("Docker output complete. Docker directory: '%s'", docker_dir) diff --git a/constructor/dockerfile_template.tmpl b/constructor/dockerfile_template.tmpl new file mode 100644 index 000000000..335bd3b12 --- /dev/null +++ b/constructor/dockerfile_template.tmpl @@ -0,0 +1,47 @@ +# Dockerfile generated by constructor {{ constructor_version }} + +########################################################## +# Stage 1: Run .sh installer +########################################################## + +FROM {{ base_image }} AS builder + +ARG PREFIX={{ default_prefix }} + +COPY {{ installer_filename }} /tmp/installer.sh + +RUN bash /tmp/installer.sh -b -p ${PREFIX} && \ + rm -rf "${PREFIX}/uninstall.sh" "${PREFIX}/_conda" && \ + find "${PREFIX}/lib" -name 'lib*.a' -delete && \ + find "${PREFIX}/lib" -name 'lib*.js.map' -delete && \ + find "${PREFIX}" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \ + "$PREFIX/bin/python" -m conda clean -afy && \ + find "${PREFIX}" -follow -type f -name '*.a' -delete + +########################################################## +# Stage 2: Final image +########################################################## + +FROM {{ base_image }} + +ARG PREFIX={{ default_prefix }} + +LABEL org.opencontainers.image.title="{{ name }}" +LABEL org.opencontainers.image.version="{{ version }}" +{%- if labels %} +{%- for k, v in labels.items() %} +LABEL {{ k }}="{{ v }}" +{%- endfor %} +{%- endif %} + +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV PATH="${PREFIX}/bin:${PATH}" + +COPY --from=builder ${PREFIX} ${PREFIX} +COPY --from=builder /root/.conda /root/.conda + +RUN echo 'export PATH=$(sed -e "s,:\?/opt/conda/bin:,," <<< "${PATH}")' >> ~/.bashrc && \ + "$PREFIX/bin/python" -m conda init --all + +CMD [ "/bin/bash" ] diff --git a/constructor/main.py b/constructor/main.py index 5a8ce9af7..10167e32b 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -40,7 +40,7 @@ def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} + os_allowed = {"linux": ("sh", "docker"), "osx": ("sh", "pkg"), "win": ("exe",)} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") @@ -399,6 +399,10 @@ def main_build( from .winexe import create as winexe_create create = winexe_create + elif itype == "docker": + from .docker_build import create as docker_create + + create = docker_create info["installer_type"] = itype info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) create(info, verbose=verbose) From d60e4b3ccf24db8df19835bd4cc8ac1fe6642d7d Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 22 Apr 2026 12:35:22 -0700 Subject: [PATCH 03/34] Revert shar changes --- constructor/shar.py | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/constructor/shar.py b/constructor/shar.py index 7429ef49f..a0b631267 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -19,7 +19,6 @@ from contextlib import nullcontext from io import BytesIO from os.path import basename, dirname, getsize, isdir, join, relpath -from pathlib import Path from .construct import ns_platform from .jinja import render_template @@ -183,6 +182,11 @@ def create(info, verbose=False): join(tmp_dir, "envs", env_name, "conda-meta", "history"), f"envs/{env_name}/conda-meta/history", ) + if os.path.exists(join(tmp_dir, "envs", env_name, "conda-meta", "frozen")): + post_t.add( + join(tmp_dir, "envs", env_name, "conda-meta", "frozen"), + f"envs/{env_name}/conda-meta/frozen", + ) extra_files = copy_extra_files(info.get("extra_files", []), tmp_dir) for path in extra_files: @@ -236,33 +240,6 @@ def create(info, verbose=False): break fo.write(chunk) - # Save payload for Docker builds if dockerfile output is requested - if "dockerfile" in str(info.get("build_outputs", [])) or info.get("installer_type") == "docker": - output_dir = Path(info["_output_dir"]) - payload_dir = output_dir / "docker" / "_payload" - payload_dir.mkdir(parents=True, exist_ok=True) - - # Save the main payload - payload_dest = payload_dir / "_conda" - shutil.copy(tarball, payload_dest) - logger.info(f"Saved conda payload: {payload_dest}") - - # Save preconda - preconda_dest = payload_dir / "preconda.tar.bz2" - shutil.copy(preconda_tarball, preconda_dest) # preconda_tarball already exists at line 94 - logger.info(f"Saved preconda: {preconda_dest}") - - # Save postconda - postconda_dest = payload_dir / "postconda.tar.bz2" - shutil.copy(postconda_tarball, postconda_dest) # postconda_tarball exists at line 95 - logger.info(f"Saved postconda: {postconda_dest}") - - # print("TMP DIR:", tmp_dir) - # print("PRECONDA:", preconda_tarball) - # print("POSTCONDA:", postconda_tarball) - # print("PAYLOAD TAR:", tarball) - # print("OUTPATH:", shar_path) - os.unlink(tarball) os.chmod(shar_path, 0o755) if not info.get("_debug"): From c3524ba26478dd39da15bea336b022b336f6026e Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 22 Apr 2026 16:25:07 -0700 Subject: [PATCH 04/34] Fix logic with building on non-native platforms --- constructor/docker_build.py | 77 ++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index 9b3626249..dc07ecc08 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -1,6 +1,7 @@ import logging import shutil import subprocess +import tempfile from pathlib import Path from jinja2 import Template @@ -8,17 +9,9 @@ logger = logging.getLogger(__name__) -DEFAULT_BASE_IMAGE = "debian:13.4-slim" -DEFAULT_BASE_IMAGE_SHA = "4ffb3a1511099754cddc70eb1b12e50ffdb67619aa0ab6c13fcd800a78ef7c7a" TEMPLATE_PATH = Path(__file__).parent / "dockerfile_template.tmpl" -ARCH_MAP = { - "64": "amd64", - "arm64": "arm64", - "aarch64": "arm64", -} - -def prepare_docker_context(info: dict) -> Path: +def prepare_docker_context(info: dict) -> tuple[Path, Path]: """Copy the .sh installer into the Docker build directory. Parameters @@ -64,13 +57,17 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: """ docker_template = Template(TEMPLATE_PATH.read_text()) - docker_base_image = info.get("docker_base_image", DEFAULT_BASE_IMAGE) - docker_base_image_sha = ":latest" if not DEFAULT_BASE_IMAGE_SHA else f"@sha256:{DEFAULT_BASE_IMAGE_SHA}" - docker_base_image_sha = info.get("docker_base_image_sha", docker_base_image_sha) + docker_base_image = info.get("docker_base_image") + if not docker_base_image: + raise RuntimeError( + "Base image for Dockerfile not specified. " + "Please set 'docker_base_image' in construct.yaml, e.g.:\n" + " docker_base_image: debian:13.4-slim@sha256:4ffb3a1511099754cddc70eb1b12e50ffdb67619aa0ab6c13fcd800a78ef7c7a\n" + ) rendered_dockerfile = docker_template.render( constructor_version=__version__, - base_image=f"{docker_base_image}{docker_base_image_sha}", + base_image=docker_base_image, default_prefix=info.get("default_prefix", "/opt/conda"), installer_filename=Path(info["_outpath"]).name, name=info["name"], @@ -86,6 +83,7 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: def build_image(info: dict, docker_dir: Path) -> None: """Optionally build the docker image from the generated Dockerfile. + Currently only supports building on linux/arm64 and linux/amd64. Parameters ---------- @@ -105,55 +103,48 @@ def build_image(info: dict, docker_dir: Path) -> None: osname, arch = info["_platform"].split("-") - if osname == "linux": - docker_cmd = ["docker", "build"] + if osname == "linux" and arch in ("amd64", "arm64"): + logger.info(f"Building Docker image on supported platform: {info['_platform']}") + elif osname == "linux" and arch not in ("amd64", "arm64"): + logger.warning( + f"Building Docker images on linux/{arch} is not fully supported. " + "Tread carefully as the resulting image may not work as expected. " + "The resulting image may fail due to architecture incompatibility. " else: - result = subprocess.run(["docker", "buildx", "version"], capture_output=True) - if result.returncode != 0: - raise RuntimeError( - "Building a Docker image for non-Linux platforms requires 'docker-buildx'. " - "Install docker-buildx and try again, or run the build on a Linux platform. " - "Alternatively, use `installer_type: docker # [linux]` in construct.yaml to " - "generate the Dockerfile without building the image." - ) - docker_arch = ARCH_MAP.get(arch) - if docker_arch is None: - raise RuntimeError( - f"Unsupported architecture for Docker build: {arch}\n" - f"Supported architectures: {', '.join(ARCH_MAP)}" - ) - docker_cmd = ["docker", "buildx", "build", "--platform", f"linux/{docker_arch}", "--load"] + raise RuntimeError( + f"Unsupported architecture for Docker build: {info['_platform']}\n" + "Currently, building Docker images is only supported on linux/amd64 and linux/arm64 platforms. " + "Please run the build on a Linux platform or alternatively, " + "use `installer_type: docker # [linux]` in construct.yaml to " + "generate the Dockerfile without building the image. Then you can build the Docker image manually using 'docker buildx' on non-Linux platforms. " + ) image_name = info.get("docker_image_name", info["name"].lower()) image_version = info.get("docker_image_version", info["version"].split("-")[0]) tag = f"{image_name}:{image_version}" - cmd = [*docker_cmd, "-t", tag, str(docker_dir)] + cmd = ["docker", "build", "-t", tag, str(docker_dir)] logger.info("Building Docker image: '%s'", tag) subprocess.run(cmd, check=True) logger.info("Docker image built: '%s'", tag) -def cleanup(info: dict, docker_dir: Path) -> None: - """Remove the Docker build context directory after building. +def cleanup(docker_dir: Path, info: dict) -> None: + """Copy final artifacts to output directory and clean up temporary files. Parameters ---------- + docker_dir: Path + Final output directory containing the Dockerfile. (``<_output_dir>/docker``) info: dict Constructor installer info dict. - docker_dir: Path - Path to the Docker directory containing the Dockerfile. """ - installer_path = Path(info["_outpath"]) - - installer_path.unlink(missing_ok=True) - docker_dir.joinpath(installer_path.name).unlink(missing_ok=True) - logger.info("Removing installers from paths: %s, %s", installer_path, docker_dir.joinpath(installer_path.name)) + if build_image == success: + installer_path.unlink(missing_ok=True) + logger.info("Removing installer from paths: %s, %s", installer_path, docker_dir.joinpath(installer_path.name)) - # TODO: Add option for agressive cleanup which would remove dockerfile if building is enabled. - # shutil.rmtree(docker_dir) - # logger.info("Cleaned up Docker build directory: '%s'", docker_dir) + # TODO: Add option for agressive cleanup which would remove dockerfile and sh installer if building is enabled. def create(info: dict, verbose: bool = False) -> None: From df67edf0b5056ebf0245c118af37f28c7ccf05ae Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 22 Apr 2026 16:25:27 -0700 Subject: [PATCH 05/34] Add mamba logic --- constructor/dockerfile_template.tmpl | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/constructor/dockerfile_template.tmpl b/constructor/dockerfile_template.tmpl index 335bd3b12..5f0402a72 100644 --- a/constructor/dockerfile_template.tmpl +++ b/constructor/dockerfile_template.tmpl @@ -43,5 +43,12 @@ COPY --from=builder /root/.conda /root/.conda RUN echo 'export PATH=$(sed -e "s,:\?/opt/conda/bin:,," <<< "${PATH}")' >> ~/.bashrc && \ "$PREFIX/bin/python" -m conda init --all + if [ -f "$PREFIX/bin/mamba" ]; then \ + if [ "$("$PREFIX/bin/mamba" --version | head -n 1 | cut -d' ' -f2 | cut -d'.' -f1)" -lt 2 ]; then \ + "$PREFIX/bin/python" -m mamba.mamba init; \ + else \ + "$PREFIX/bin/mamba" shell init; \ + fi; \ + fi CMD [ "/bin/bash" ] From b71c8f9b304496b6090ff8b37500542be9cbadd2 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 07:47:15 -0700 Subject: [PATCH 06/34] Require base image to be provided in construct.yaml --- constructor/_schema.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/constructor/_schema.py b/constructor/_schema.py index 52681f463..b4049355f 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -408,7 +408,7 @@ class ConstructorConfiguration(BaseModel): The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well - as `sh` on Linux and `exe` on Windows. + as `sh` and `docker` on Linux and `exe` on Windows. """ license_file: NonEmptyStr | None = None @@ -855,16 +855,11 @@ class ConstructorConfiguration(BaseModel): message: "This base environment is frozen and cannot be modified." ``` """ - docker_base_image: NonEmptyStr | None = None + docker_base_image: Annotated[str, Field(min_length=1)] | None = None """ Base image to use for docker builds when `installer_type` includes `docker`. - Defaults to `debian:13.4-slim`. - """ - docker_base_image_sha: NonEmptyStr | None = None - """ - If `docker_base_image` is provided, you can also provide a SHA256 hash of - the image to ensure the integrity of the base image used for the build. - Otherwise, the base image defaults to `latest`. + Should be a specific image reference. For reproducibility, please specify a SHA256 digest. + For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`. """ docker_tag: NonEmptyStr | None = None """ From ec455fa5fd219ac31af1986c8c5ccd8490d93167 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 07:48:00 -0700 Subject: [PATCH 07/34] Update docker_build.py --- constructor/docker_build.py | 109 +++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index dc07ecc08..395ac2d7a 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -2,6 +2,7 @@ import shutil import subprocess import tempfile +import platform from pathlib import Path from jinja2 import Template @@ -11,32 +12,43 @@ TEMPLATE_PATH = Path(__file__).parent / "dockerfile_template.tmpl" -def prepare_docker_context(info: dict) -> tuple[Path, Path]: +DOCKER_PLATFORM_MAP = { + "linux-64": "linux/amd64", + "linux-aarch64": "linux/arm64", + "linux-armv7l": "linux/arm/v7", + "linux-32": "linux/386", + "linux-ppc64le": "linux/ppc64le", + "linux-s390x": "linux/s390x", + "osx-arm64": "linux/arm64", + "osx-64": "linux/amd64", +} + +def prepare_docker_context(info: dict, tmp_dir: Path) -> tuple[Path, Path]: """Copy the .sh installer into the Docker build directory. Parameters ---------- info: dict - Constructor installer info dict. Must contain ``_outpath`` and ``_output_dir`` pointing to the built .sh installer and output directory respectively. + Constructor installer info dict. Must contain ``_outpath`` and ``_output_dir`` pointing to the built .sh + installer and output directory respectively. + tmp_dir: Path + Path to a temporary directory to stage the Docker build context. The .sh installer will be copied to this directory. Returns ------- Path - Path to the Docker build directory (``<_output_dir>/docker``). + Path to the tmp Docker build directory (``<_output_dir>/docker``). """ - docker_dir = Path(info["_output_dir"]) / "docker" - docker_dir.mkdir(parents=True, exist_ok=True) - installer_path = Path(info["_outpath"]) if not installer_path.exists(): raise RuntimeError( f"Expected .sh installer not found: {installer_path}\n" ) - shutil.copy(installer_path, docker_dir / installer_path.name) - logger.info("Copied installer to Docker directory: %s", docker_dir / installer_path.name) + shutil.copy(installer_path, tmp_dir / installer_path.name) + logger.info("Copied installer to tmp directory: %s", tmp_dir / installer_path.name) - return docker_dir + return tmp_dir def generate_dockerfile(info: dict, docker_dir: Path) -> Path: @@ -55,6 +67,10 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: Path Path to the generated Dockerfile. ``/Dockerfile``. """ + from .conda_interface import MatchSpec + + specs = {MatchSpec(spec).name for spec in info.get("specs", ())} + docker_template = Template(TEMPLATE_PATH.read_text()) docker_base_image = info.get("docker_base_image") @@ -64,6 +80,12 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: "Please set 'docker_base_image' in construct.yaml, e.g.:\n" " docker_base_image: debian:13.4-slim@sha256:4ffb3a1511099754cddc70eb1b12e50ffdb67619aa0ab6c13fcd800a78ef7c7a\n" ) + if "@" not in docker_base_image: + logger.warning( + "No SHA256 digest specified for docker_base_image. " + "Consider specifying a digest to ensure the integrity of the base image used for the build, e.g.:\n" + " docker_base_image: debian:13.4-slim@sha256:4ffb3a1511099754cddc70eb1b12e50ffdb67619aa0ab6c13fcd800a78ef7c7a\n" + ) rendered_dockerfile = docker_template.render( constructor_version=__version__, @@ -73,6 +95,9 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: name=info["name"], version=info["version"], labels=info.get("docker_labels", {}), + init_cmd = "$PREFIX/bin/mamba shell" if "mamba" in specs else "$PREFIX/bin/python -m conda", + register_envs=info.get("register_envs", True), + keep_pkgs=info.get("keep_pkgs", False), ) dockerfile_path = docker_dir / "Dockerfile" @@ -93,60 +118,39 @@ def build_image(info: dict, docker_dir: Path) -> None: Path to the Docker directory containing the Dockerfile. """ + if info.get("_platform") not in DOCKER_PLATFORM_MAP: + logger.warning( + f"Building Docker images is not supported on platform '{info['_platform']}'. " + "Skipping Docker build. You can still generate the Dockerfile by and build the image manually using 'docker buildx' on a supported platform or using Docker Desktop. " + "Supported platforms for Docker build are: linux/amd64 and linux/arm64." + ) + return + if shutil.which("docker") is None: raise RuntimeError( "Building a Docker image requires the 'docker' CLI tool to be installed and available in PATH. " "Install Docker Desktop or Docker Engine to proceed, or " - "use `installer_type: docker # [linux]` in construct.yaml to " + "use `installer_type: docker` in construct.yaml to " "generate the Dockerfile without building the image." ) - osname, arch = info["_platform"].split("-") - - if osname == "linux" and arch in ("amd64", "arm64"): - logger.info(f"Building Docker image on supported platform: {info['_platform']}") - elif osname == "linux" and arch not in ("amd64", "arm64"): - logger.warning( - f"Building Docker images on linux/{arch} is not fully supported. " - "Tread carefully as the resulting image may not work as expected. " - "The resulting image may fail due to architecture incompatibility. " - else: + docker_platform = DOCKER_PLATFORM_MAP.get(info["_platform"]) + if docker_platform is None: raise RuntimeError( - f"Unsupported architecture for Docker build: {info['_platform']}\n" - "Currently, building Docker images is only supported on linux/amd64 and linux/arm64 platforms. " - "Please run the build on a Linux platform or alternatively, " - "use `installer_type: docker # [linux]` in construct.yaml to " - "generate the Dockerfile without building the image. Then you can build the Docker image manually using 'docker buildx' on non-Linux platforms. " + f"Unsupported platform for Docker build: '{info['_platform']}'. " + "Supported platforms are: {', '.join(DOCKER_PLATFORM_MAP)}." ) image_name = info.get("docker_image_name", info["name"].lower()) image_version = info.get("docker_image_version", info["version"].split("-")[0]) tag = f"{image_name}:{image_version}" - cmd = ["docker", "build", "-t", tag, str(docker_dir)] + cmd = ["docker", "buildx", "build", "--load", "--platform", docker_platform, "-t", tag, str(docker_dir)] logger.info("Building Docker image: '%s'", tag) subprocess.run(cmd, check=True) logger.info("Docker image built: '%s'", tag) -def cleanup(docker_dir: Path, info: dict) -> None: - """Copy final artifacts to output directory and clean up temporary files. - - Parameters - ---------- - docker_dir: Path - Final output directory containing the Dockerfile. (``<_output_dir>/docker``) - info: dict - Constructor installer info dict. - - """ - if build_image == success: - installer_path.unlink(missing_ok=True) - logger.info("Removing installer from paths: %s, %s", installer_path, docker_dir.joinpath(installer_path.name)) - - # TODO: Add option for agressive cleanup which would remove dockerfile and sh installer if building is enabled. - - def create(info: dict, verbose: bool = False) -> None: """Build a Docker output @@ -159,12 +163,17 @@ def create(info: dict, verbose: bool = False) -> None: Defaults to ``False``. """ - docker_dir = prepare_docker_context(info) - generate_dockerfile(info, docker_dir) + with tempfile.TemporaryDirectory() as temp_dir: + tmp_path = Path(temp_dir) + prepare_docker_context(info, tmp_path) + generate_dockerfile(info, tmp_path) - if info.get("docker_build"): - build_image(info, docker_dir) + if info.get("docker_build"): + build_image(info, tmp_path) - cleanup(info, docker_dir) + output_docker_dir = Path(info["_output_dir"]) / "docker" + output_docker_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(tmp_path / "Dockerfile", output_docker_dir / "Dockerfile") + shutil.copy(tmp_path / Path(info["_outpath"]).name, output_docker_dir / Path(info["_outpath"]).name) - logger.info("Docker output complete. Docker directory: '%s'", docker_dir) + logger.info("Docker output complete. Docker directory: '%s'", output_docker_dir) From 1fd5f980f7706121630bb94a4ff84c12fd044a79 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 07:48:34 -0700 Subject: [PATCH 08/34] Use existing vars in template --- constructor/dockerfile_template.tmpl | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/constructor/dockerfile_template.tmpl b/constructor/dockerfile_template.tmpl index 5f0402a72..755a05026 100644 --- a/constructor/dockerfile_template.tmpl +++ b/constructor/dockerfile_template.tmpl @@ -10,12 +10,15 @@ ARG PREFIX={{ default_prefix }} COPY {{ installer_filename }} /tmp/installer.sh -RUN bash /tmp/installer.sh -b -p ${PREFIX} && \ - rm -rf "${PREFIX}/uninstall.sh" "${PREFIX}/_conda" && \ +RUN sh /tmp/installer.sh -b -p ${PREFIX} && \ + rm -rf "${PREFIX}/uninstall.sh" "${PREFIX}/_conda" || true && \ find "${PREFIX}/lib" -name 'lib*.a' -delete && \ find "${PREFIX}/lib" -name 'lib*.js.map' -delete && \ find "${PREFIX}" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \ - "$PREFIX/bin/python" -m conda clean -afy && \ + {% if not keep_pkgs %} + rm -rf "$PREFIX/pkgs" && \ + "$PREFIX/bin/python" -m conda clean -afy || "$PREFIX/bin/mamba" clean -afy && \ + {% endif %} find "${PREFIX}" -follow -type f -name '*.a' -delete ########################################################## @@ -39,16 +42,11 @@ ENV LC_ALL=C.UTF-8 ENV PATH="${PREFIX}/bin:${PATH}" COPY --from=builder ${PREFIX} ${PREFIX} +{% if register_envs %} COPY --from=builder /root/.conda /root/.conda +{% endif %} -RUN echo 'export PATH=$(sed -e "s,:\?/opt/conda/bin:,," <<< "${PATH}")' >> ~/.bashrc && \ - "$PREFIX/bin/python" -m conda init --all - if [ -f "$PREFIX/bin/mamba" ]; then \ - if [ "$("$PREFIX/bin/mamba" --version | head -n 1 | cut -d' ' -f2 | cut -d'.' -f1)" -lt 2 ]; then \ - "$PREFIX/bin/python" -m mamba.mamba init; \ - else \ - "$PREFIX/bin/mamba" shell init; \ - fi; \ - fi +RUN echo 'export PATH=$(sed -e "s,:\?{{ default_prefix }}/bin:,," <<< "${PATH}")' >> ~/.bashrc && \ + {{ init_cmd }} init --all CMD [ "/bin/bash" ] From 5028672d40f2da0d4fcc0a65068f81cf3a3a9aca Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 07:48:53 -0700 Subject: [PATCH 09/34] Add docker as installer type --- constructor/main.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 10167e32b..8a72a9441 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -40,10 +40,24 @@ def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh", "docker"), "osx": ("sh", "pkg"), "win": ("exe",)} + os_allowed = {"linux": ("sh", "docker"), "osx": ("sh", "pkg", "docker"), "win": ("exe",)} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") + docker_build = info.get("docker_build", False) + + if docker_build and osname == "win": + sys.exit( + "Error: 'docker_build' is not supported on Windows. " + "Run the build on a Linux or macOS host instead." + ) + + if docker_build and itype in ("pkg", "exe"): + sys.exit( + "Error: 'docker_build' not compatible with installer_type. " + "Use installer_type: 'sh', 'docker', or 'all' to build a Docker image." + ) + if not itype: return os_allowed[osname][:1] elif itype == "all": @@ -56,6 +70,8 @@ def get_installer_type(info: dict): sys.exit( "Error: invalid installer type '%s' for %s; allowed: %s" % (itype, osname, os_allowed) ) + elif itype == "docker" or docker_build: + return ("sh", "docker") else: return (itype,) From 487d802a0f0db65018a65e7a9b072f1619a888cc Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 07:49:30 -0700 Subject: [PATCH 10/34] Add example construct.yaml for tests --- examples/docker_build/construct.yaml | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 examples/docker_build/construct.yaml diff --git a/examples/docker_build/construct.yaml b/examples/docker_build/construct.yaml new file mode 100644 index 000000000..907028889 --- /dev/null +++ b/examples/docker_build/construct.yaml @@ -0,0 +1,29 @@ +name: test_docker +version: 1.0.0 + +channels: + - conda-forge + +specs: + - python + - numpy + - conda + +installer_type: docker +# Generates Dockerfile and stages the installer. Does not build the image. + +docker_build: true +# Builds the Docker image after generating the Dockerfile and staging installer files. + +docker_base_image: "debian:13.4-slim@sha256:cedb1ef40439206b673ee8b33a46a03a0c9fa90bf3732f54704f99cb061d2c5a" + +keep_pkgs: true + +register_envs: true + +labels: + maintainer: "jaidarice" + description: "Test Docker image built with constructor." + +post_install: post_install_script.sh +# post_install runs during .sh installer execution during Stage 1 of the Docker build. From c46044a16da1bc07bce40542ed1abca2962284c9 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 07:50:24 -0700 Subject: [PATCH 11/34] Add test --- tests/test_examples.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 4ec5c9a64..bb6fabd94 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1554,3 +1554,24 @@ def test_frozen_environment(tmp_path, request, has_conflict): s in c.value.stderr for s in ("RuntimeError", "freeze_base / freeze_env", "extra_files", "base") ) + + +@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") +@pytest.mark.skipif(not shutil.which("docker"), reason="Docker not available") +def test_docker_build(tmp_path): + input_path = _example_path("docker_build") + image_name = "docker-test" + output_path = tmp_path / "output" + docker_dir = output_path / "docker" + + try: + for installer, _ in create_installer(input_path, output_path): + assert (docker_dir / "Dockerfile").exists() + assert (docker_dir / installer.name).exists() + + subprocess.run(["docker", "build", "-t", image_name, str(docker_dir)], check=True) + + assert subprocess.run(["docker", "run", "--rm", image_name, "conda", "--version"], capture_output=True, text=True, check=True) + + finally: + subprocess.run(["docker", "rmi", image_name], check=False) From ae648a5219e6742356ebfd59fbce3352fa734801 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 08:12:30 -0700 Subject: [PATCH 12/34] Add clean command --- constructor/docker_build.py | 4 ++-- constructor/dockerfile_template.tmpl | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index 395ac2d7a..00f95795f 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -2,7 +2,6 @@ import shutil import subprocess import tempfile -import platform from pathlib import Path from jinja2 import Template @@ -90,8 +89,9 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: rendered_dockerfile = docker_template.render( constructor_version=__version__, base_image=docker_base_image, - default_prefix=info.get("default_prefix", "/opt/conda"), + default_prefix=info.get("default_prefix", f"/opt/{info.get('installer_name').lower()}"), installer_filename=Path(info["_outpath"]).name, + clean_cmd="$PREFIX/bin/mamba clean -afy" if "mamba" in specs else "$PREFIX/bin/conda clean -afy" if "conda" in specs else "", name=info["name"], version=info["version"], labels=info.get("docker_labels", {}), diff --git a/constructor/dockerfile_template.tmpl b/constructor/dockerfile_template.tmpl index 755a05026..cf2e7a6cc 100644 --- a/constructor/dockerfile_template.tmpl +++ b/constructor/dockerfile_template.tmpl @@ -16,8 +16,7 @@ RUN sh /tmp/installer.sh -b -p ${PREFIX} && \ find "${PREFIX}/lib" -name 'lib*.js.map' -delete && \ find "${PREFIX}" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \ {% if not keep_pkgs %} - rm -rf "$PREFIX/pkgs" && \ - "$PREFIX/bin/python" -m conda clean -afy || "$PREFIX/bin/mamba" clean -afy && \ + rm -rf "$PREFIX/pkgs" && {{ clean_cmd }} && \ {% endif %} find "${PREFIX}" -follow -type f -name '*.a' -delete From 539500ccac2dde1bba26caa02fe8c8ffd4f09368 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 08:13:01 -0700 Subject: [PATCH 13/34] Call proper docker command in test --- tests/test_examples.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index bb6fabd94..fea086045 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1569,9 +1569,11 @@ def test_docker_build(tmp_path): assert (docker_dir / "Dockerfile").exists() assert (docker_dir / installer.name).exists() - subprocess.run(["docker", "build", "-t", image_name, str(docker_dir)], check=True) + subprocess.run(["docker", "buildx", "build", "--load", "--platform", platform, "-t", image_name, str(docker_dir)], check=True) - assert subprocess.run(["docker", "run", "--rm", image_name, "conda", "--version"], capture_output=True, text=True, check=True) + result = subprocess.run(["docker", "run", "--rm", image_name, "conda", "--version"], capture_output=True, text=True, check=True) + + assert "conda" in result.stdout finally: subprocess.run(["docker", "rmi", image_name], check=False) From 43ca2133a1b5ed91b3dd0f85859fd912e8b716b0 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 08:47:40 -0700 Subject: [PATCH 14/34] Fix pre-commit errors --- constructor/docker_build.py | 43 +++++++++++++++++++--------- constructor/dockerfile_template.tmpl | 5 ++-- constructor/main.py | 2 +- examples/docker_build/construct.yaml | 3 -- tests/test_examples.py | 9 ++++-- 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index 00f95795f..fac9dde9b 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -3,6 +3,7 @@ import subprocess import tempfile from pathlib import Path + from jinja2 import Template from . import __version__ @@ -22,7 +23,8 @@ "osx-64": "linux/amd64", } -def prepare_docker_context(info: dict, tmp_dir: Path) -> tuple[Path, Path]: + +def prepare_docker_context(info: dict, tmp_dir: Path) -> Path: """Copy the .sh installer into the Docker build directory. Parameters @@ -36,13 +38,11 @@ def prepare_docker_context(info: dict, tmp_dir: Path) -> tuple[Path, Path]: Returns ------- Path - Path to the tmp Docker build directory (``<_output_dir>/docker``). + Path to the tmp Docker build directory. """ installer_path = Path(info["_outpath"]) if not installer_path.exists(): - raise RuntimeError( - f"Expected .sh installer not found: {installer_path}\n" - ) + raise RuntimeError(f"Expected .sh installer not found: {installer_path}\n") shutil.copy(installer_path, tmp_dir / installer_path.name) logger.info("Copied installer to tmp directory: %s", tmp_dir / installer_path.name) @@ -52,19 +52,19 @@ def prepare_docker_context(info: dict, tmp_dir: Path) -> tuple[Path, Path]: def generate_dockerfile(info: dict, docker_dir: Path) -> Path: """ - Render the Dockerfile template and write it to `/Dockerfile`. + Render the Dockerfile template and write it to the Docker build directory. Parameters ---------- info: dict Constructor installer info dict. docker_dir: Path - Path to the Docker build directory (``<_output_dir>/docker``) returned by prepare_docker_context(). + Path to the Docker build directory returned by prepare_docker_context(). Returns ------- Path - Path to the generated Dockerfile. ``/Dockerfile``. + Path to the generated Dockerfile. """ from .conda_interface import MatchSpec @@ -91,11 +91,15 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: base_image=docker_base_image, default_prefix=info.get("default_prefix", f"/opt/{info.get('installer_name').lower()}"), installer_filename=Path(info["_outpath"]).name, - clean_cmd="$PREFIX/bin/mamba clean -afy" if "mamba" in specs else "$PREFIX/bin/conda clean -afy" if "conda" in specs else "", + clean_cmd="$PREFIX/bin/mamba clean -afy" + if "mamba" in specs + else "$PREFIX/bin/conda clean -afy" + if "conda" in specs + else "", name=info["name"], version=info["version"], labels=info.get("docker_labels", {}), - init_cmd = "$PREFIX/bin/mamba shell" if "mamba" in specs else "$PREFIX/bin/python -m conda", + init_cmd="$PREFIX/bin/mamba shell" if "mamba" in specs else "$PREFIX/bin/python -m conda", register_envs=info.get("register_envs", True), keep_pkgs=info.get("keep_pkgs", False), ) @@ -108,7 +112,7 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: def build_image(info: dict, docker_dir: Path) -> None: """Optionally build the docker image from the generated Dockerfile. - Currently only supports building on linux/arm64 and linux/amd64. + Currently supported on linux and macOS platforms. Parameters ---------- @@ -145,12 +149,23 @@ def build_image(info: dict, docker_dir: Path) -> None: image_version = info.get("docker_image_version", info["version"].split("-")[0]) tag = f"{image_name}:{image_version}" - cmd = ["docker", "buildx", "build", "--load", "--platform", docker_platform, "-t", tag, str(docker_dir)] + cmd = [ + "docker", + "buildx", + "build", + "--load", + "--platform", + docker_platform, + "-t", + tag, + str(docker_dir), + ] logger.info("Building Docker image: '%s'", tag) subprocess.run(cmd, check=True) logger.info("Docker image built: '%s'", tag) + def create(info: dict, verbose: bool = False) -> None: """Build a Docker output @@ -174,6 +189,8 @@ def create(info: dict, verbose: bool = False) -> None: output_docker_dir = Path(info["_output_dir"]) / "docker" output_docker_dir.mkdir(parents=True, exist_ok=True) shutil.copy(tmp_path / "Dockerfile", output_docker_dir / "Dockerfile") - shutil.copy(tmp_path / Path(info["_outpath"]).name, output_docker_dir / Path(info["_outpath"]).name) + shutil.copy( + tmp_path / Path(info["_outpath"]).name, output_docker_dir / Path(info["_outpath"]).name + ) logger.info("Docker output complete. Docker directory: '%s'", output_docker_dir) diff --git a/constructor/dockerfile_template.tmpl b/constructor/dockerfile_template.tmpl index cf2e7a6cc..12d41f3c8 100644 --- a/constructor/dockerfile_template.tmpl +++ b/constructor/dockerfile_template.tmpl @@ -15,9 +15,8 @@ RUN sh /tmp/installer.sh -b -p ${PREFIX} && \ find "${PREFIX}/lib" -name 'lib*.a' -delete && \ find "${PREFIX}/lib" -name 'lib*.js.map' -delete && \ find "${PREFIX}" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \ - {% if not keep_pkgs %} - rm -rf "$PREFIX/pkgs" && {{ clean_cmd }} && \ - {% endif %} + {% if not keep_pkgs %}rm -rf "$PREFIX/pkgs" && {{ clean_cmd }} && \ + {% endif %}\ find "${PREFIX}" -follow -type f -name '*.a' -delete ########################################################## diff --git a/constructor/main.py b/constructor/main.py index 8a72a9441..4e6c9263a 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -49,7 +49,7 @@ def get_installer_type(info: dict): if docker_build and osname == "win": sys.exit( "Error: 'docker_build' is not supported on Windows. " - "Run the build on a Linux or macOS host instead." + "Run the build on Linux or macOS instead." ) if docker_build and itype in ("pkg", "exe"): diff --git a/examples/docker_build/construct.yaml b/examples/docker_build/construct.yaml index 907028889..f501fb964 100644 --- a/examples/docker_build/construct.yaml +++ b/examples/docker_build/construct.yaml @@ -24,6 +24,3 @@ register_envs: true labels: maintainer: "jaidarice" description: "Test Docker image built with constructor." - -post_install: post_install_script.sh -# post_install runs during .sh installer execution during Stage 1 of the Docker build. diff --git a/tests/test_examples.py b/tests/test_examples.py index fea086045..c2af4f5db 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1569,9 +1569,12 @@ def test_docker_build(tmp_path): assert (docker_dir / "Dockerfile").exists() assert (docker_dir / installer.name).exists() - subprocess.run(["docker", "buildx", "build", "--load", "--platform", platform, "-t", image_name, str(docker_dir)], check=True) - - result = subprocess.run(["docker", "run", "--rm", image_name, "conda", "--version"], capture_output=True, text=True, check=True) + result = subprocess.run( + ["docker", "run", "--rm", image_name, "conda", "--version"], + capture_output=True, + text=True, + check=True, + ) assert "conda" in result.stdout From 0bf8de5933a9da095766384c5e5465ed19124014 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 08:50:38 -0700 Subject: [PATCH 15/34] Add docstring to beginning of file --- constructor/docker_build.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index fac9dde9b..312b5a9ed 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -1,3 +1,5 @@ +"""Logic for creating a Dockerfile and/or building Docker images from Constructor installers.""" + import logging import shutil import subprocess From 9f20cbee8cba6be58ab031d72fe88ff0ded5d287 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 09:00:46 -0700 Subject: [PATCH 16/34] Use schema vars properly --- constructor/_schema.py | 6 ++++-- constructor/docker_build.py | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/constructor/_schema.py b/constructor/_schema.py index b4049355f..a2e5ba275 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -863,12 +863,14 @@ class ConstructorConfiguration(BaseModel): """ docker_tag: NonEmptyStr | None = None """ - Tag to use for the built docker image when `installer_type` includes `docker`. + Tag to use for the built docker image. If not provided, it will default to `:`. """ docker_labels: dict[NonEmptyStr, NonEmptyStr] = {} """ - Labels to add to the built docker image when `installer_type` includes `docker`. + Additional labels to add to the built docker image. + The labels `org.opencontainers.image.title` and `org.opencontainers.image.version` are + set automatically from `name` and `version`. """ diff --git a/constructor/docker_build.py b/constructor/docker_build.py index 312b5a9ed..d70ddf3c3 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -91,7 +91,7 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: rendered_dockerfile = docker_template.render( constructor_version=__version__, base_image=docker_base_image, - default_prefix=info.get("default_prefix", f"/opt/{info.get('installer_name').lower()}"), + default_prefix=info.get("default_prefix", f"/opt/{info['name'].lower()}"), installer_filename=Path(info["_outpath"]).name, clean_cmd="$PREFIX/bin/mamba clean -afy" if "mamba" in specs @@ -147,9 +147,7 @@ def build_image(info: dict, docker_dir: Path) -> None: "Supported platforms are: {', '.join(DOCKER_PLATFORM_MAP)}." ) - image_name = info.get("docker_image_name", info["name"].lower()) - image_version = info.get("docker_image_version", info["version"].split("-")[0]) - tag = f"{image_name}:{image_version}" + tag = info.get("docker_tag", f"{info['name']}:{info['version'].split('-')[0]}") cmd = [ "docker", From 65f9fd8a0ac0986b3b01da738c6281b042666139 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 09:40:30 -0700 Subject: [PATCH 17/34] Use correct image name in test --- tests/test_examples.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index c2af4f5db..d73e55635 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1560,10 +1560,14 @@ def test_frozen_environment(tmp_path, request, has_conflict): @pytest.mark.skipif(not shutil.which("docker"), reason="Docker not available") def test_docker_build(tmp_path): input_path = _example_path("docker_build") - image_name = "docker-test" output_path = tmp_path / "output" docker_dir = output_path / "docker" + yaml = YAML() + with open(input_path / "construct.yaml") as f: + config = yaml.load(f) + image_name = f"{config['name'].lower()}:{config['version'].split('-')[0]}" + try: for installer, _ in create_installer(input_path, output_path): assert (docker_dir / "Dockerfile").exists() From 4fd08688d96c2683736e9ccbb1a6a2276826d154 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 09:48:15 -0700 Subject: [PATCH 18/34] Update docs --- CONSTRUCT.md | 20 +++++++++++++++++++- docs/source/construct-yaml.md | 20 +++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 17a71302f..117c72e8e 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -235,10 +235,11 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `docker`: a Dockerfile that replicates the installation environment The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well -as `sh` on Linux and `exe` on Windows. +as `sh` and `docker` on Linux and `exe` on Windows. ### `license_file` @@ -679,6 +680,23 @@ freeze_base: message: "This base environment is frozen and cannot be modified." ``` +### `docker_base_image` + +Base image to use for docker builds when `installer_type` includes `docker`. +Should be a specific image reference. For reproducibility, please specify a SHA256 digest. +For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`. + +### `docker_tag` + +Tag to use for the built docker image. +If not provided, it will default to `:`. + +### `docker_labels` + +Additional labels to add to the built docker image. +The labels `org.opencontainers.image.title` and `org.opencontainers.image.version` are +set automatically from `name` and `version`. + ## Available selectors - `aarch64` diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 17a71302f..117c72e8e 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -235,10 +235,11 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `docker`: a Dockerfile that replicates the installation environment The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well -as `sh` on Linux and `exe` on Windows. +as `sh` and `docker` on Linux and `exe` on Windows. ### `license_file` @@ -679,6 +680,23 @@ freeze_base: message: "This base environment is frozen and cannot be modified." ``` +### `docker_base_image` + +Base image to use for docker builds when `installer_type` includes `docker`. +Should be a specific image reference. For reproducibility, please specify a SHA256 digest. +For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`. + +### `docker_tag` + +Tag to use for the built docker image. +If not provided, it will default to `:`. + +### `docker_labels` + +Additional labels to add to the built docker image. +The labels `org.opencontainers.image.title` and `org.opencontainers.image.version` are +set automatically from `name` and `version`. + ## Available selectors - `aarch64` From 8b10804701a72595d6fa09dfeee212f5d288c922 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 09:53:14 -0700 Subject: [PATCH 19/34] Add news file --- news/1219-docker-implementation | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 news/1219-docker-implementation diff --git a/news/1219-docker-implementation b/news/1219-docker-implementation new file mode 100644 index 000000000..a9275dbab --- /dev/null +++ b/news/1219-docker-implementation @@ -0,0 +1,21 @@ +### Enhancements + +* Add docker support, enabling constructor to generate a Dockerfile and optionally build a Docker image. (#1219) + * `installer_type: docker` generates the Dockerfile + * `docker_build: true` builds the Docker image + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* From 01a9982934d6f5cb973919e15ee066c12398bc66 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 10:21:10 -0700 Subject: [PATCH 20/34] Do not generate file extension .docker --- constructor/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 4e6c9263a..d570415fd 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -420,7 +420,8 @@ def main_build( create = docker_create info["installer_type"] = itype - info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) + if itype != "docker": + info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) create(info, verbose=verbose) if len(itypes) > 1: info_dicts.append(info.copy()) From d86ee52d3034d72c09427af5ef56ddb906e440d0 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 11:31:02 -0700 Subject: [PATCH 21/34] Fix typos --- constructor/main.py | 2 +- examples/docker_build/construct.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index d570415fd..bbfc8a3d8 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -40,7 +40,7 @@ def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh", "docker"), "osx": ("sh", "pkg", "docker"), "win": ("exe",)} + os_allowed = {"linux": ("sh", "docker"), "osx": ("sh", "pkg"), "win": ("exe",)} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") diff --git a/examples/docker_build/construct.yaml b/examples/docker_build/construct.yaml index f501fb964..2b882f201 100644 --- a/examples/docker_build/construct.yaml +++ b/examples/docker_build/construct.yaml @@ -21,6 +21,6 @@ keep_pkgs: true register_envs: true -labels: +docker_labels: maintainer: "jaidarice" description: "Test Docker image built with constructor." From 5784073e232836809f3077a0852158c7cd1d60fb Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 11:54:19 -0700 Subject: [PATCH 22/34] Always use sh for docker --- constructor/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/constructor/main.py b/constructor/main.py index bbfc8a3d8..7c3491c4f 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -422,6 +422,8 @@ def main_build( info["installer_type"] = itype if itype != "docker": info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) + else: + info["_outpath"] = abspath(join(output_dir, get_output_filename(info))).replace(".docker", ".sh") create(info, verbose=verbose) if len(itypes) > 1: info_dicts.append(info.copy()) From b2ff9d5683295e348b80576506a89d9253e45037 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 11:54:50 -0700 Subject: [PATCH 23/34] Pre-commit fix --- constructor/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 7c3491c4f..8b2e68df7 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -423,7 +423,9 @@ def main_build( if itype != "docker": info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) else: - info["_outpath"] = abspath(join(output_dir, get_output_filename(info))).replace(".docker", ".sh") + info["_outpath"] = abspath(join(output_dir, get_output_filename(info))).replace( + ".docker", ".sh" + ) create(info, verbose=verbose) if len(itypes) > 1: info_dicts.append(info.copy()) From f97e163598f8143da490ea86cd5f11e1156baa51 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 14:19:57 -0700 Subject: [PATCH 24/34] Remove docker from os_allowed --- constructor/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 8b2e68df7..d415db0f9 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -40,7 +40,7 @@ def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh", "docker"), "osx": ("sh", "pkg"), "win": ("exe",)} + os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") From 15a8b1f1a914c9484b5a99572244d7cbaa45f952 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 15:39:14 -0700 Subject: [PATCH 25/34] Regenerate schema --- constructor/data/construct.schema.json | 46 ++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index f0178d738..86de9d07b 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -245,7 +245,8 @@ "all", "exe", "pkg", - "sh" + "sh", + "docker" ], "title": "InstallerTypes", "type": "string" @@ -638,6 +639,47 @@ "description": "Set default installation prefix for domain users. If not provided, the installation prefix for domain users will be `%LOCALAPPDATA%\\`. By default, it is different from the `default_prefix` value to avoid installing the distribution into the roaming profile. Environment variables will be expanded at install time. Windows only.", "title": "Default Prefix Domain User" }, + "docker_base_image": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base image to use for docker builds when `installer_type` includes `docker`. Should be a specific image reference. For reproducibility, please specify a SHA256 digest. For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`.", + "title": "Docker Base Image" + }, + "docker_labels": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "default": {}, + "description": "Additional labels to add to the built docker image. The labels `org.opencontainers.image.title` and `org.opencontainers.image.version` are set automatically from `name` and `version`.", + "propertyNames": { + "minLength": 1 + }, + "title": "Docker Labels", + "type": "object" + }, + "docker_tag": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Tag to use for the built docker image. If not provided, it will default to `:`.", + "title": "Docker Tag" + }, "environment": { "anyOf": [ { @@ -864,7 +906,7 @@ } ], "default": null, - "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", + "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\n- `docker`: a Dockerfile that replicates the installation environment\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` and `docker` on Linux and `exe` on Windows.", "title": "Installer Type" }, "keep_pkgs": { From a5704bbec46b482916881381464e7288d76a9a92 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 6 May 2026 16:05:14 -0700 Subject: [PATCH 26/34] Add docker_build to schema --- CONSTRUCT.md | 4 ++++ constructor/_schema.py | 4 ++++ constructor/data/construct.schema.json | 6 ++++++ docs/source/construct-yaml.md | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 117c72e8e..6de20ef0e 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -697,6 +697,10 @@ Additional labels to add to the built docker image. The labels `org.opencontainers.image.title` and `org.opencontainers.image.version` are set automatically from `name` and `version`. +### `docker_build` + +Option to build the docker image after creating the Dockerfile. + ## Available selectors - `aarch64` diff --git a/constructor/_schema.py b/constructor/_schema.py index a2e5ba275..47a24831b 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -872,6 +872,10 @@ class ConstructorConfiguration(BaseModel): The labels `org.opencontainers.image.title` and `org.opencontainers.image.version` are set automatically from `name` and `version`. """ + docker_build: bool = False + """ + Option to build the docker image after creating the Dockerfile. + """ def fix_descriptions(obj): diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 86de9d07b..4c83748b0 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -653,6 +653,12 @@ "description": "Base image to use for docker builds when `installer_type` includes `docker`. Should be a specific image reference. For reproducibility, please specify a SHA256 digest. For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`.", "title": "Docker Base Image" }, + "docker_build": { + "default": false, + "description": "Option to build the docker image after creating the Dockerfile.", + "title": "Docker Build", + "type": "boolean" + }, "docker_labels": { "additionalProperties": { "minLength": 1, diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 117c72e8e..6de20ef0e 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -697,6 +697,10 @@ Additional labels to add to the built docker image. The labels `org.opencontainers.image.title` and `org.opencontainers.image.version` are set automatically from `name` and `version`. +### `docker_build` + +Option to build the docker image after creating the Dockerfile. + ## Available selectors - `aarch64` From 8db9e9600ad4bf068a32a140ab059b14ec1307c5 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 08:04:51 -0700 Subject: [PATCH 27/34] Make whitespace adjustments --- constructor/dockerfile_template.tmpl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/constructor/dockerfile_template.tmpl b/constructor/dockerfile_template.tmpl index 12d41f3c8..468c11e92 100644 --- a/constructor/dockerfile_template.tmpl +++ b/constructor/dockerfile_template.tmpl @@ -10,14 +10,14 @@ ARG PREFIX={{ default_prefix }} COPY {{ installer_filename }} /tmp/installer.sh -RUN sh /tmp/installer.sh -b -p ${PREFIX} && \ - rm -rf "${PREFIX}/uninstall.sh" "${PREFIX}/_conda" || true && \ +RUN sh /tmp/installer.sh -b -p "${PREFIX}" && \ + rm -rf "${PREFIX}/uninstall.sh" && \ + rm -rf "${PREFIX}/_conda" || true && \ find "${PREFIX}/lib" -name 'lib*.a' -delete && \ find "${PREFIX}/lib" -name 'lib*.js.map' -delete && \ find "${PREFIX}" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \ - {% if not keep_pkgs %}rm -rf "$PREFIX/pkgs" && {{ clean_cmd }} && \ - {% endif %}\ - find "${PREFIX}" -follow -type f -name '*.a' -delete + {% if not keep_pkgs %}rm -rf "${PREFIX}/pkgs" && {{ clean_cmd }} && \ + {% endif %}find "${PREFIX}" -follow -type f -name '*.a' -delete ########################################################## # Stage 2: Final image From 6d1d1180dc286a050ab1a6a8def708e2ca57fb7d Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 08:25:02 -0700 Subject: [PATCH 28/34] Fix logic regarding base image requirement --- constructor/docker_build.py | 47 +++++++++++++++++++++---------------- constructor/main.py | 34 ++++++++++++++++++++------- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index d70ddf3c3..42683662a 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -1,4 +1,4 @@ -"""Logic for creating a Dockerfile and/or building Docker images from Constructor installers.""" +"""Logic for creating a Dockerfile and/or building portable Docker images from Constructor installers.""" import logging import shutil @@ -66,7 +66,7 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: Returns ------- Path - Path to the generated Dockerfile. + Path to the generated Dockerfile, or None if generation is skipped. """ from .conda_interface import MatchSpec @@ -76,11 +76,11 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: docker_base_image = info.get("docker_base_image") if not docker_base_image: - raise RuntimeError( - "Base image for Dockerfile not specified. " - "Please set 'docker_base_image' in construct.yaml, e.g.:\n" - " docker_base_image: debian:13.4-slim@sha256:4ffb3a1511099754cddc70eb1b12e50ffdb67619aa0ab6c13fcd800a78ef7c7a\n" + logger.warning( + "Skipping Dockerfile generation. 'docker_base_image' not specified in construct.yaml." ) + return None + if "@" not in docker_base_image: logger.warning( "No SHA256 digest specified for docker_base_image. " @@ -95,9 +95,7 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: installer_filename=Path(info["_outpath"]).name, clean_cmd="$PREFIX/bin/mamba clean -afy" if "mamba" in specs - else "$PREFIX/bin/conda clean -afy" - if "conda" in specs - else "", + else "$PREFIX/bin/conda clean -afy", name=info["name"], version=info["version"], labels=info.get("docker_labels", {}), @@ -112,7 +110,7 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: return dockerfile_path -def build_image(info: dict, docker_dir: Path) -> None: +def build_image(info: dict, docker_dir: Path, docker_dest: Path) -> None: """Optionally build the docker image from the generated Dockerfile. Currently supported on linux and macOS platforms. @@ -122,12 +120,14 @@ def build_image(info: dict, docker_dir: Path) -> None: Constructor installer info dict. docker_dir: Path Path to the Docker directory containing the Dockerfile. + docker_dest: Path + Path to the output directory where the built Docker image tarball will be saved. """ if info.get("_platform") not in DOCKER_PLATFORM_MAP: logger.warning( f"Building Docker images is not supported on platform '{info['_platform']}'. " - "Skipping Docker build. You can still generate the Dockerfile by and build the image manually using 'docker buildx' on a supported platform or using Docker Desktop. " + "Skipping Docker build. You can still build the image manually using 'docker buildx' on a supported platform or Docker Desktop. " "Supported platforms for Docker build are: linux/amd64 and linux/arm64." ) return @@ -144,26 +144,28 @@ def build_image(info: dict, docker_dir: Path) -> None: if docker_platform is None: raise RuntimeError( f"Unsupported platform for Docker build: '{info['_platform']}'. " - "Supported platforms are: {', '.join(DOCKER_PLATFORM_MAP)}." + f"Supported platforms are: {', '.join(DOCKER_PLATFORM_MAP)}." ) - tag = info.get("docker_tag", f"{info['name']}:{info['version'].split('-')[0]}") + tag = info.get("docker_tag", f"{info['name'].lower()}:{info['version'].split('-')[0]}") + tarball_dest = docker_dest / f"{tag.replace(':', '-')}-{docker_platform.replace('/', '-')}.tar" cmd = [ "docker", "buildx", "build", - "--load", + str(docker_dir), "--platform", docker_platform, "-t", tag, - str(docker_dir), + "--output", + f"type=docker,dest={tarball_dest}", ] logger.info("Building Docker image: '%s'", tag) subprocess.run(cmd, check=True) - logger.info("Docker image built: '%s'", tag) + logger.info("Docker image saved to: '%s'", tarball_dest) def create(info: dict, verbose: bool = False) -> None: @@ -180,17 +182,22 @@ def create(info: dict, verbose: bool = False) -> None: """ with tempfile.TemporaryDirectory() as temp_dir: tmp_path = Path(temp_dir) + info["_outpath"] = info["_outpath"].replace(".docker", ".sh") prepare_docker_context(info, tmp_path) - generate_dockerfile(info, tmp_path) - - if info.get("docker_build"): - build_image(info, tmp_path) + dockerfile = generate_dockerfile(info, tmp_path) + if dockerfile is None: + logger.warning("Dockerfile generation skipped. Docker image will not be built.") + return output_docker_dir = Path(info["_output_dir"]) / "docker" output_docker_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(tmp_path / "Dockerfile", output_docker_dir / "Dockerfile") shutil.copy( tmp_path / Path(info["_outpath"]).name, output_docker_dir / Path(info["_outpath"]).name ) + if info.get("docker_build"): + build_image(info, output_docker_dir) + logger.info("Docker output complete. Docker directory: '%s'", output_docker_dir) diff --git a/constructor/main.py b/constructor/main.py index d415db0f9..69da8cdc9 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -40,8 +40,15 @@ def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} - all_allowed = set(sum(os_allowed.values(), ("all",))) + os_allowed = { + "linux": ("sh",), + "osx": ( + "sh", + "pkg", + ), + "win": ("exe",), + } + all_allowed = set(sum(os_allowed.values(), ("all", "docker"))) itype = info.get("installer_type") docker_build = info.get("docker_build", False) @@ -51,11 +58,10 @@ def get_installer_type(info: dict): "Error: 'docker_build' is not supported on Windows. " "Run the build on Linux or macOS instead." ) - if docker_build and itype in ("pkg", "exe"): sys.exit( - "Error: 'docker_build' not compatible with installer_type. " - "Use installer_type: 'sh', 'docker', or 'all' to build a Docker image." + "Error: 'docker_build' is not compatible with installer_type 'pkg' or 'exe'. " + "Use installer_type: 'sh', 'docker', or omit installer_type." ) if not itype: @@ -65,13 +71,13 @@ def get_installer_type(info: dict): elif itype not in all_allowed: all_allowed = ", ".join(sorted(all_allowed)) sys.exit("Error: invalid installer type '%s'; allowed: %s" % (itype, all_allowed)) + elif itype == "docker" or docker_build: + return ("sh", "docker") elif itype not in os_allowed[osname]: os_allowed = ", ".join(sorted(os_allowed[osname])) sys.exit( "Error: invalid installer type '%s' for %s; allowed: %s" % (itype, osname, os_allowed) ) - elif itype == "docker" or docker_build: - return ("sh", "docker") else: return (itype,) @@ -218,6 +224,12 @@ def main_build( info["_debug"] = debug itypes = get_installer_type(info) + if "docker" in itypes and not info.get("docker_base_image"): + sys.exit( + "Error: docker_base_image is required when building Docker output. " + "Please specify a base image using the 'docker_base_image' key in construct.yaml." + ) + if platform != cc_platform and "pkg" in itypes and not cc_platform.startswith("osx-"): sys.exit("Error: cannot construct a macOS 'pkg' installer on '%s'" % cc_platform) @@ -429,7 +441,13 @@ def main_build( create(info, verbose=verbose) if len(itypes) > 1: info_dicts.append(info.copy()) - logger.info("Successfully created '%(_outpath)s'.", info) + if itype == "docker": + logger.info( + "Docker output complete. Docker directory: '%s'", + Path(info["_output_dir"]) / "docker", + ) + else: + logger.info("Successfully created '%(_outpath)s'.", info) # Merge info files for each installer type if len(itypes) > 1: From f65ecf30bccbf29bafc0336932840ba78a5cf000 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 08:25:35 -0700 Subject: [PATCH 29/34] Make image portable --- constructor/_schema.py | 14 ++++++++------ tests/test_examples.py | 6 +++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/constructor/_schema.py b/constructor/_schema.py index 47a24831b..bde37d452 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -404,11 +404,11 @@ class ConstructorConfiguration(BaseModel): - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS - - `docker`: a Dockerfile that replicates the installation environment + - `docker`: generates a Dockerfile that replicates the installation environment (Does not build image. See `docker_build` option below.) The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well - as `sh` and `docker` on Linux and `exe` on Windows. + as `sh` on Linux and `exe` on Windows. """ license_file: NonEmptyStr | None = None @@ -857,13 +857,13 @@ class ConstructorConfiguration(BaseModel): """ docker_base_image: Annotated[str, Field(min_length=1)] | None = None """ - Base image to use for docker builds when `installer_type` includes `docker`. + Base image to use for docker builds when `installer_type` includes `docker` or `docker_build` is True. Should be a specific image reference. For reproducibility, please specify a SHA256 digest. - For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`. + For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, a warning will be shown. """ docker_tag: NonEmptyStr | None = None """ - Tag to use for the built docker image. + Tag to use for the docker image. If not provided, it will default to `:`. """ docker_labels: dict[NonEmptyStr, NonEmptyStr] = {} @@ -874,7 +874,9 @@ class ConstructorConfiguration(BaseModel): """ docker_build: bool = False """ - Option to build the docker image after creating the Dockerfile. + If `True`, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball. + ``--.tar`` will be created in the output docker directory. + Requires `docker_base_image` to be specified. """ diff --git a/tests/test_examples.py b/tests/test_examples.py index d73e55635..b79a0b333 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1561,7 +1561,7 @@ def test_frozen_environment(tmp_path, request, has_conflict): def test_docker_build(tmp_path): input_path = _example_path("docker_build") output_path = tmp_path / "output" - docker_dir = output_path / "docker" + docker_dir = output_path / "installer" / "docker" yaml = YAML() with open(input_path / "construct.yaml") as f: @@ -1573,6 +1573,10 @@ def test_docker_build(tmp_path): assert (docker_dir / "Dockerfile").exists() assert (docker_dir / installer.name).exists() + tarballs = list(docker_dir.glob("*.tar")) + assert tarballs, "No Docker image tarball found in docker dir" + subprocess.run(["docker", "load", "-i", str(tarballs[0])], check=True) + result = subprocess.run( ["docker", "run", "--rm", image_name, "conda", "--version"], capture_output=True, From 989fb3019f8140f56e60cb060c9970ee111d1abf Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 08:26:03 -0700 Subject: [PATCH 30/34] Update wording --- examples/docker_build/construct.yaml | 4 ++-- news/1219-docker-implementation | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/docker_build/construct.yaml b/examples/docker_build/construct.yaml index 2b882f201..19d5c08a9 100644 --- a/examples/docker_build/construct.yaml +++ b/examples/docker_build/construct.yaml @@ -10,10 +10,10 @@ specs: - conda installer_type: docker -# Generates Dockerfile and stages the installer. Does not build the image. +# Generates Dockerfile and stages the installer. docker_build: true -# Builds the Docker image after generating the Dockerfile and staging installer files. +# Additionally builds a portable Docker image from the generated Dockerfile. docker_base_image: "debian:13.4-slim@sha256:cedb1ef40439206b673ee8b33a46a03a0c9fa90bf3732f54704f99cb061d2c5a" diff --git a/news/1219-docker-implementation b/news/1219-docker-implementation index a9275dbab..b3dbbea7f 100644 --- a/news/1219-docker-implementation +++ b/news/1219-docker-implementation @@ -1,8 +1,7 @@ ### Enhancements -* Add docker support, enabling constructor to generate a Dockerfile and optionally build a Docker image. (#1219) - * `installer_type: docker` generates the Dockerfile - * `docker_build: true` builds the Docker image +* Add `installer_type: docker` support to generate a Dockerfile from a constructor build to be used as-is or modified. (#1219) +* Add `docker_build: true` support to build a portable Docker image. (#1219) ### Bug fixes From 7338b72471a037025a189bd0553c0bcc32f34af4 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 08:26:35 -0700 Subject: [PATCH 31/34] Update docs --- CONSTRUCT.md | 14 ++++++++------ constructor/data/construct.schema.json | 8 ++++---- docs/source/construct-yaml.md | 14 ++++++++------ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 6de20ef0e..ace5183bf 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -235,11 +235,11 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS -- `docker`: a Dockerfile that replicates the installation environment +- `docker`: generates a Dockerfile that replicates the installation environment (Does not build image. See `docker_build` option below.) The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well -as `sh` and `docker` on Linux and `exe` on Windows. +as `sh` on Linux and `exe` on Windows. ### `license_file` @@ -682,13 +682,13 @@ freeze_base: ### `docker_base_image` -Base image to use for docker builds when `installer_type` includes `docker`. +Base image to use for docker builds when `installer_type` includes `docker` or `docker_build` is True. Should be a specific image reference. For reproducibility, please specify a SHA256 digest. -For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`. +For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, a warning will be shown. ### `docker_tag` -Tag to use for the built docker image. +Tag to use for the docker image. If not provided, it will default to `:`. ### `docker_labels` @@ -699,7 +699,9 @@ set automatically from `name` and `version`. ### `docker_build` -Option to build the docker image after creating the Dockerfile. +If `True`, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball. +``--.tar`` will be created in the output docker directory. +Requires `docker_base_image` to be specified. ## Available selectors diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 4c83748b0..5f6a6c2c1 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -650,12 +650,12 @@ } ], "default": null, - "description": "Base image to use for docker builds when `installer_type` includes `docker`. Should be a specific image reference. For reproducibility, please specify a SHA256 digest. For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`.", + "description": "Base image to use for docker builds when `installer_type` includes `docker` or `docker_build` is True. Should be a specific image reference. For reproducibility, please specify a SHA256 digest. For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, a warning will be shown.", "title": "Docker Base Image" }, "docker_build": { "default": false, - "description": "Option to build the docker image after creating the Dockerfile.", + "description": "If `True`, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball. ``--.tar`` will be created in the output docker directory. Requires `docker_base_image` to be specified.", "title": "Docker Build", "type": "boolean" }, @@ -683,7 +683,7 @@ } ], "default": null, - "description": "Tag to use for the built docker image. If not provided, it will default to `:`.", + "description": "Tag to use for the docker image. If not provided, it will default to `:`.", "title": "Docker Tag" }, "environment": { @@ -912,7 +912,7 @@ } ], "default": null, - "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\n- `docker`: a Dockerfile that replicates the installation environment\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` and `docker` on Linux and `exe` on Windows.", + "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\n- `docker`: generates a Dockerfile that replicates the installation environment (Does not build image. See `docker_build` option below.)\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", "title": "Installer Type" }, "keep_pkgs": { diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 6de20ef0e..ace5183bf 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -235,11 +235,11 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS -- `docker`: a Dockerfile that replicates the installation environment +- `docker`: generates a Dockerfile that replicates the installation environment (Does not build image. See `docker_build` option below.) The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well -as `sh` and `docker` on Linux and `exe` on Windows. +as `sh` on Linux and `exe` on Windows. ### `license_file` @@ -682,13 +682,13 @@ freeze_base: ### `docker_base_image` -Base image to use for docker builds when `installer_type` includes `docker`. +Base image to use for docker builds when `installer_type` includes `docker` or `docker_build` is True. Should be a specific image reference. For reproducibility, please specify a SHA256 digest. -For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, it defaults to `latest`. +For example: `debian:13.4-slim@sha256:abc123...`. If the digest is not provided, a warning will be shown. ### `docker_tag` -Tag to use for the built docker image. +Tag to use for the docker image. If not provided, it will default to `:`. ### `docker_labels` @@ -699,7 +699,9 @@ set automatically from `name` and `version`. ### `docker_build` -Option to build the docker image after creating the Dockerfile. +If `True`, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball. +``--.tar`` will be created in the output docker directory. +Requires `docker_base_image` to be specified. ## Available selectors From 120d13554b0eb4036779540fb3115d56da18dbed Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 11:45:52 -0700 Subject: [PATCH 32/34] Revert to using one path --- constructor/docker_build.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index 42683662a..0de103497 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -110,7 +110,7 @@ def generate_dockerfile(info: dict, docker_dir: Path) -> Path: return dockerfile_path -def build_image(info: dict, docker_dir: Path, docker_dest: Path) -> None: +def build_image(info: dict, docker_dir: Path) -> None: """Optionally build the docker image from the generated Dockerfile. Currently supported on linux and macOS platforms. @@ -119,9 +119,7 @@ def build_image(info: dict, docker_dir: Path, docker_dest: Path) -> None: info: dict Constructor installer info dict. docker_dir: Path - Path to the Docker directory containing the Dockerfile. - docker_dest: Path - Path to the output directory where the built Docker image tarball will be saved. + Path to the Docker directory containing the Docker outputs. """ if info.get("_platform") not in DOCKER_PLATFORM_MAP: @@ -148,7 +146,8 @@ def build_image(info: dict, docker_dir: Path, docker_dest: Path) -> None: ) tag = info.get("docker_tag", f"{info['name'].lower()}:{info['version'].split('-')[0]}") - tarball_dest = docker_dest / f"{tag.replace(':', '-')}-{docker_platform.replace('/', '-')}.tar" + docker_dir = Path(info["_output_dir"]) / "docker" + tarball_dest = docker_dir / f"{tag.replace(':', '-')}-{docker_platform.replace('/', '-')}.tar" cmd = [ "docker", From be48dd16bf93e0ce1a8dcfe8f47c05f180160c09 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 12:36:28 -0700 Subject: [PATCH 33/34] Revert back to docker load --- constructor/docker_build.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index 0de103497..dc57b014e 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -158,12 +158,16 @@ def build_image(info: dict, docker_dir: Path) -> None: docker_platform, "-t", tag, - "--output", - f"type=docker,dest={tarball_dest}", + "--load", ] logger.info("Building Docker image: '%s'", tag) subprocess.run(cmd, check=True) + logger.info("Docker image '%s' built successfully.", tag) + + logger.info("Saving Docker image to tarball: '%s'", tarball_dest) + with open(tarball_dest, "wb") as f: + subprocess.run(["docker", "save", tag], check=True, stdout=f) logger.info("Docker image saved to: '%s'", tarball_dest) From 18b7001e19495372631f51ea8129789468abbff7 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 8 May 2026 12:42:06 -0700 Subject: [PATCH 34/34] Add output --- constructor/docker_build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/docker_build.py b/constructor/docker_build.py index dc57b014e..47349ac78 100644 --- a/constructor/docker_build.py +++ b/constructor/docker_build.py @@ -167,7 +167,7 @@ def build_image(info: dict, docker_dir: Path) -> None: logger.info("Saving Docker image to tarball: '%s'", tarball_dest) with open(tarball_dest, "wb") as f: - subprocess.run(["docker", "save", tag], check=True, stdout=f) + subprocess.run(["docker", "save", tag, "-o", str(tarball_dest)], check=True, stdout=f) logger.info("Docker image saved to: '%s'", tarball_dest)