Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
81dfce0
Add payload accessibility
Jrice1317 Apr 20, 2026
749abd4
First pass
Jrice1317 Apr 22, 2026
d60e4b3
Revert shar changes
Jrice1317 Apr 22, 2026
c3524ba
Fix logic with building on non-native platforms
Jrice1317 Apr 22, 2026
df67edf
Add mamba logic
Jrice1317 Apr 22, 2026
b71c8f9
Require base image to be provided in construct.yaml
Jrice1317 May 6, 2026
ec455fa
Update docker_build.py
Jrice1317 May 6, 2026
1fd5f98
Use existing vars in template
Jrice1317 May 6, 2026
5028672
Add docker as installer type
Jrice1317 May 6, 2026
487d802
Add example construct.yaml for tests
Jrice1317 May 6, 2026
c46044a
Add test
Jrice1317 May 6, 2026
ae648a5
Add clean command
Jrice1317 May 6, 2026
539500c
Call proper docker command in test
Jrice1317 May 6, 2026
43ca213
Fix pre-commit errors
Jrice1317 May 6, 2026
0bf8de5
Add docstring to beginning of file
Jrice1317 May 6, 2026
9f20cbe
Use schema vars properly
Jrice1317 May 6, 2026
65f9fd8
Use correct image name in test
Jrice1317 May 6, 2026
4fd0868
Update docs
Jrice1317 May 6, 2026
8b10804
Add news file
Jrice1317 May 6, 2026
01a9982
Do not generate file extension .docker
Jrice1317 May 6, 2026
d86ee52
Fix typos
Jrice1317 May 6, 2026
5784073
Always use sh for docker
Jrice1317 May 6, 2026
b2ff9d5
Pre-commit fix
Jrice1317 May 6, 2026
f97e163
Remove docker from os_allowed
Jrice1317 May 6, 2026
15a8b1f
Regenerate schema
Jrice1317 May 6, 2026
a5704bb
Add docker_build to schema
Jrice1317 May 6, 2026
8db9e96
Make whitespace adjustments
Jrice1317 May 8, 2026
6d1d118
Fix logic regarding base image requirement
Jrice1317 May 8, 2026
f65ecf3
Make image portable
Jrice1317 May 8, 2026
989fb30
Update wording
Jrice1317 May 8, 2026
7338b72
Update docs
Jrice1317 May 8, 2026
120d135
Revert to using one path
Jrice1317 May 8, 2026
be48dd1
Revert back to docker load
Jrice1317 May 8, 2026
18b7001
Add output
Jrice1317 May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CONSTRUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ 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`: 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
Expand Down Expand Up @@ -679,6 +680,29 @@ 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` 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.

### `docker_tag`

Tag to use for the docker image.
If not provided, it will default to `<name>:<version>`.

### `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`.

### `docker_build`

If `True`, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball.
``<name>-<version>-<platform>.tar`` will be created in the output docker directory.
Requires `docker_base_image` to be specified.


## Available selectors
- `aarch64`
Expand Down
25 changes: 25 additions & 0 deletions constructor/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class InstallerTypes(StrEnum):
EXE = "exe"
PKG = "pkg"
SH = "sh"
DOCKER = "docker"


class PkgDomains(StrEnum):
Expand Down Expand Up @@ -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`: 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
Expand Down Expand Up @@ -853,6 +855,29 @@ class ConstructorConfiguration(BaseModel):
message: "This base environment is frozen and cannot be modified."
```
"""
docker_base_image: Annotated[str, Field(min_length=1)] | None = None
"""
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.
"""
docker_tag: NonEmptyStr | None = None
"""
Tag to use for the docker image.
If not provided, it will default to `<name>:<version>`.
"""
docker_labels: dict[NonEmptyStr, NonEmptyStr] = {}
"""
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: bool = False
"""
If `True`, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball.
``<name>-<version>-<platform>.tar`` will be created in the output docker directory.
Requires `docker_base_image` to be specified.
"""


def fix_descriptions(obj):
Expand Down
52 changes: 50 additions & 2 deletions constructor/data/construct.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@
"all",
"exe",
"pkg",
"sh"
"sh",
"docker"
],
"title": "InstallerTypes",
"type": "string"
Expand Down Expand Up @@ -638,6 +639,53 @@
"description": "Set default installation prefix for domain users. If not provided, the installation prefix for domain users will be `%LOCALAPPDATA%\\<NAME>`. 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` 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": "If `True`, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball. ``<name>-<version>-<platform>.tar`` will be created in the output docker directory. Requires `docker_base_image` to be specified.",
"title": "Docker Build",
"type": "boolean"
},
"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 docker image. If not provided, it will default to `<name>:<version>`.",
"title": "Docker Tag"
},
"environment": {
"anyOf": [
{
Expand Down Expand Up @@ -864,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\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`: 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": {
Expand Down
206 changes: 206 additions & 0 deletions constructor/docker_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""Logic for creating a Dockerfile and/or building portable Docker images from Constructor installers."""

import logging
import shutil
import subprocess
import tempfile
from pathlib import Path

from jinja2 import Template

from . import __version__

logger = logging.getLogger(__name__)

TEMPLATE_PATH = Path(__file__).parent / "dockerfile_template.tmpl"

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) -> 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.
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 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")

shutil.copy(installer_path, tmp_dir / installer_path.name)
logger.info("Copied installer to tmp directory: %s", tmp_dir / installer_path.name)

return tmp_dir


def generate_dockerfile(info: dict, docker_dir: Path) -> Path:
"""
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 returned by prepare_docker_context().

Returns
-------
Path
Path to the generated Dockerfile, or None if generation is skipped.
"""
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")
if not docker_base_image:
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. "
"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__,
base_image=docker_base_image,
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
else "$PREFIX/bin/conda clean -afy",
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"
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.
Currently supported on linux and macOS platforms.

Parameters
----------
info: dict
Constructor installer info dict.
docker_dir: Path
Path to the Docker directory containing the Docker outputs.

"""
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 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

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` in construct.yaml to "
"generate the Dockerfile without building the image."
)

docker_platform = DOCKER_PLATFORM_MAP.get(info["_platform"])
if docker_platform is None:
raise RuntimeError(
f"Unsupported platform for Docker build: '{info['_platform']}'. "
f"Supported platforms are: {', '.join(DOCKER_PLATFORM_MAP)}."
)

tag = info.get("docker_tag", f"{info['name'].lower()}:{info['version'].split('-')[0]}")
docker_dir = Path(info["_output_dir"]) / "docker"
tarball_dest = docker_dir / f"{tag.replace(':', '-')}-{docker_platform.replace('/', '-')}.tar"

cmd = [
"docker",
"buildx",
"build",
str(docker_dir),
"--platform",
docker_platform,
"-t",
tag,
"--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, "-o", str(tarball_dest)], check=True, stdout=f)
logger.info("Docker image saved to: '%s'", tarball_dest)


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``.

"""
with tempfile.TemporaryDirectory() as temp_dir:
tmp_path = Path(temp_dir)
info["_outpath"] = info["_outpath"].replace(".docker", ".sh")
prepare_docker_context(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)
Loading
Loading