Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 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
583191d
Apply suggestions from code review
Jrice1317 May 14, 2026
b047963
Update logic
Jrice1317 May 14, 2026
80ee0a7
Merge branch 'docker-implementation' of https://github.com/Jrice1317/…
Jrice1317 May 14, 2026
2dbe4eb
Change wording in schema
Jrice1317 May 14, 2026
c1876f5
Be more descriptive
Jrice1317 May 15, 2026
c0ab7cb
Be more generalized
Jrice1317 May 15, 2026
963126c
Use multiline block
Jrice1317 May 15, 2026
40475f9
Refine logic
Jrice1317 May 15, 2026
9fea3d4
Move check to main if docker installed for building image
Jrice1317 May 15, 2026
1720895
Change docker_build to docker_image
Jrice1317 May 15, 2026
8735a9a
Expand tests
Jrice1317 May 15, 2026
56957c2
Pre-commit fixes
Jrice1317 May 15, 2026
ceb9a3c
Change logic
Jrice1317 May 15, 2026
511ade6
Update docs
Jrice1317 May 15, 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` or image of 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
Expand Down Expand Up @@ -679,6 +680,29 @@ freeze_base:
message: "This base environment is frozen and cannot be modified."
```

### `docker_base_image`

Required to use docker-related features.
Base image to use for docker builds and Dockerfiles. This can be any valid docker image reference, including a tag and/or digest.
For example: `debian:13.4-slim@sha256:abc123...`.

### `docker_tag`

Tag to use for the docker image.
If not provided, it will default to `<name>-<version>:<platform>-<arch>`.
Has no effect if not using the docker build output feature.

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

If set, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball either uncompressed or compressed.
``<name>-<version>-<platform>-<arch>-docker.tar`` will be created in the output docker directory.


## 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` or image of 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
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of prefixing all fields with docker_*, would it make more sense to make docker its own key with all other properties being a subkey? I know that's not our current convention, so I'm not 100% sure about that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it could be cleaner, but unless we can do a broader refactor, I think docker_* keeps things consistent with how it's handled today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, please volunteer me if/when we can do a schema refactor. (:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sticking with the current schema is a fair choice. A schema refactor would constitute a major breaking change that I don't foresee ourselves doing unless we absolutely have to.

"""
Required to use docker-related features.
Base image to use for docker builds and Dockerfiles. This can be any valid docker image reference, including a tag and/or digest.
For example: `debian:13.4-slim@sha256:abc123...`.
"""
docker_tag: NonEmptyStr | None = None
"""
Tag to use for the docker image.
If not provided, it will default to `<name>-<version>:<platform>-<arch>`.
Has no effect if not using the docker build output feature.
"""
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_image: Literal["tar", "gz", "zst"] = "tar"
"""
If set, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball either uncompressed or compressed.
``<name>-<version>-<platform>-<arch>-docker.tar`` will be created in the output docker directory.
"""


def fix_descriptions(obj):
Expand Down
57 changes: 55 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,58 @@
"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": "Required to use docker-related features. Base image to use for docker builds and Dockerfiles. This can be any valid docker image reference, including a tag and/or digest. For example: `debian:13.4-slim@sha256:abc123...`.",
"title": "Docker Base Image"
},
"docker_image": {
"default": "tar",
"description": "If set, builds a docker image using the Dockerfile generated by constructor and saves it as a portable tarball either uncompressed or compressed. ``<name>-<version>-<platform>-<arch>-docker.tar`` will be created in the output docker directory.",
"enum": [
"tar",
"gz",
"zst"
],
"title": "Docker Image",
"type": "string"
},
"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>:<platform>-<arch>`. Has no effect if not using the docker build output feature.",
"title": "Docker Tag"
},
"environment": {
"anyOf": [
{
Expand Down Expand Up @@ -864,7 +917,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` or image of 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` on Linux and `exe` on Windows.",
"title": "Installer Type"
},
"keep_pkgs": {
Expand Down
156 changes: 156 additions & 0 deletions constructor/docker_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""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",
}


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.
"""
from .conda_interface import MatchSpec

specs = {MatchSpec(spec).name for spec in info.get("specs", ())}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
specs = {MatchSpec(spec).name for spec in info.get("specs", ())}
specs = {MatchSpec(spec).name for spec in info.get("specs", ())}
has_mamba = "mamba" in specs

That will make things more clear later. Should probably also be done with conda because not every installation has conda or mamba.

has_mamba = "mamba" in specs

docker_template = Template(TEMPLATE_PATH.read_text())

rendered_dockerfile = docker_template.render(
constructor_version=__version__,
base_image=info.get("docker_base_image"),
default_prefix=info.get("default_prefix", f"/opt/{info['name'].lower()}"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the actual prefix, is it not?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm intentionally being explicit here since in the schema we have:

   default_prefix: NonEmptyStr | None = None
    """
    Set default install prefix. On Linux, if not provided, the default prefix
    is `${HOME}/<NAME>` (or, if `HOME` is not set, `/opt/<NAME>`). On Windows,
    this is used only for "Just Me" installations; for "All Users" installations,
    use the `default_prefix_all_users` key. If not provided, the default prefix
    is `%USERPROFILE%\\<NAME>`. Environment variables will be expanded at
    install time.
    """

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still use the template variable name prefix. Yes, it comes from default_prefix, but there is no such thing as a "default" prefix in Docker because there is no way to choose it. The schema description should be updated to reflect the Docker default value.

installer_filename=Path(info["_outpath"]).name,
name=info["name"],
version=info["version"],
labels=info.get("docker_labels", {}),
init_cmd="$PREFIX/bin/mamba shell" if has_mamba else "$PREFIX/bin/python -m conda",
register_envs=info.get("register_envs"),
keep_pkgs=info.get("keep_pkgs"),
)

logger.info("Writing Dockerfile...")
dockerfile_path = docker_dir / "Dockerfile"
dockerfile_path.write_text(rendered_dockerfile)
return dockerfile_path


def build_image(info: dict, docker_dir: Path) -> Path:
"""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.

Returns
-------
Path
Path to the saved Docker image tarball.

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

if info.get("docker_tag") and ":" not in info.get("docker_tag"):
raise ValueError(
"Invalid docker_tag: '{info['docker_tag']}'. Must be in format 'name:tag'."
)

tag = info.get("docker_tag", f"{info['name'].lower()}:{info['version']}")
tarball_dest = docker_dir / f"{Path(info['_outpath']).stem}-docker.tar"

cmd = [
"docker",
"buildx",
"build",
str(docker_dir),
"--platform",
docker_platform,
"-t",
tag,
Comment on lines +107 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is where I'm wondering if we need the tag - can we also retrieve the Docker image based on the emitted SHA?

Copy link
Copy Markdown
Contributor Author

@Jrice1317 Jrice1317 May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry I'm not understanding the ask here. If we do not provide a tag, the image would show up like <none> <none>.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you thinking we use the SHA to pass into docker save instead of the tag?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tag right now is mandatory docker build input in this implementation. However, locally, docker build can be run without a tag. So, is a tag required for this to work? Could we also create a portable image without creating a tag? The SHA is one option - I don't know docker build well enough to confidently answer that question, I'm afraid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it is not required to work, but we most definitely should use a tag. When someone tries to run the image, one of the easiest ways to do that is to use the tag. If we don't provide a tag, there would be a <none> <none> and that doesn't look good or safe. So, I decided to default to the <name>:<version> but the user could choose their own tag by providing it via the docker_tag.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't provide a tag, there would be a and that doesn't look good or safe.

I'm not sure I understand where the <none> <none> is coming from - can you provide the command output so I can follow? And can you elaborate on the security concerns here? Tags are, in general, less safe than the image SHA. Creating a default tag is a decision that isn't easily reversed because creating an untagged image would be impossible with the current schema - so documenting security concerns is important here.

The tag provides some predictability to the code though and an untagged image requires --iidfile to reliably find the image SHA. That's currently the only argument I see for requiring that the image be tagged.

On a very different note: as I was building my own images, docker build tends to push to the local container and image registry (both tagged and untagged), so constructor needs to clean that up afterwards.

"--load",
]

logger.info("Building Docker image: '%s'", tag)
subprocess.run(cmd, check=True)

logger.info("Saving Docker image to tarball: '%s'", tarball_dest)
subprocess.run(["docker", "save", tag, "-o", str(tarball_dest)], check=True)
return 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:
docker_tmp_dir = Path(temp_dir)

installer_path = Path(info["_outpath"])
if not installer_path.exists():
raise RuntimeError(f"Expected .sh installer not found: {installer_path}")
shutil.copy(installer_path, docker_tmp_dir / installer_path.name)
logger.info("Copied installer to build directory.")

generate_dockerfile(info, docker_tmp_dir)

if info.get("docker_image") == "tar":
tarball = build_image(info, docker_tmp_dir)
if tarball:
shutil.copy(tarball, Path(info["_output_dir"]) / tarball.name)
else:
output_dir = Path(info["_output_dir"]) / installer_path.stem
output_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(docker_tmp_dir / "Dockerfile", output_dir / "Dockerfile")
shutil.copy(
docker_tmp_dir / Path(info["_outpath"]).name,
output_dir / Path(info["_outpath"]).name,
)

logger.info("Docker output complete. Docker directory: '%s'", info["_output_dir"])
49 changes: 49 additions & 0 deletions constructor/dockerfile_template.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 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 sh /tmp/installer.sh -b -p "${PREFIX}" && \
rm -f "${PREFIX}/uninstall.sh" && \
rm -f "${PREFIX}/_conda" && \
find "${PREFIX}" -follow -type f -name '*.js.map' -delete && \
find "${PREFIX}" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \
{%- if not keep_pkgs %}
rm -rf "${PREFIX}/pkgs" && \
{%- endif %}
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 }}"
{%- for k, v in labels.items() %}
LABEL {{ k }}="{{ v }}"
{%- endfor %}

ENV LANG=C.UTF-8
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,:\?{{ default_prefix }}/bin:,," <<< "${PATH}")' >> ~/.bashrc && \
/ {{ init_cmd }} init --all

CMD [ "/bin/bash" ]
Loading
Loading