dr-environment 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dr_environment/__init__.py +14 -0
- dr_environment/cli.py +91 -0
- dr_environment/recipe/__init__.py +19 -0
- dr_environment/recipe/build.py +99 -0
- dr_environment/recipe/cache/__init__.py +15 -0
- dr_environment/recipe/cache/stages.py +151 -0
- dr_environment/recipe/discover.py +88 -0
- dr_environment/recipe/hooks.py +57 -0
- dr_environment/recipe/layout.py +109 -0
- dr_environment/recipe/manifests.py +60 -0
- dr_environment/recipe/models.py +62 -0
- dr_environment/recipe/render.py +139 -0
- dr_environment/recipe/templates/docker_context/00-base.fragment.j2 +96 -0
- dr_environment/recipe/templates/docker_context/01-user.fragment.j2 +39 -0
- dr_environment/recipe/templates/docker_context/02-versions.fragment.j2 +141 -0
- dr_environment/recipe/templates/docker_context/03-build-deps.fragment.j2 +44 -0
- dr_environment/recipe/templates/docker_context/04-kernel.fragment.j2 +73 -0
- dr_environment/recipe/templates/docker_context/99-offline.fragment.j2 +98 -0
- dr_environment/recipe/templates/docker_context/build-deps/build-requirements.txt +11 -0
- dr_environment/recipe/templates/docker_context/kernel/agent/agent.py +67 -0
- dr_environment/recipe/templates/docker_context/kernel/agent/cgroup_watchers.py +181 -0
- dr_environment/recipe/templates/docker_context/kernel/common-user-limits.sh +38 -0
- dr_environment/recipe/templates/docker_context/kernel/extensions/README.md +2 -0
- dr_environment/recipe/templates/docker_context/kernel/extensions/dataframe_formatter.py +344 -0
- dr_environment/recipe/templates/docker_context/kernel/ipython_config.py +17 -0
- dr_environment/recipe/templates/docker_context/kernel/jupyter_kernel_gateway_config.py +33 -0
- dr_environment/recipe/templates/docker_context/kernel/kernel.json +15 -0
- dr_environment/recipe/templates/docker_context/kernel/notebooks-path.sh +17 -0
- dr_environment/recipe/templates/docker_context/kernel/requirements.txt +8 -0
- dr_environment/recipe/templates/docker_context/kernel/setup-caches.sh +49 -0
- dr_environment/recipe/templates/docker_context/kernel/setup-prompt.sh +20 -0
- dr_environment/recipe/templates/docker_context/kernel/setup-ssh.sh +42 -0
- dr_environment/recipe/templates/docker_context/kernel/setup-venv.sh +97 -0
- dr_environment/recipe/templates/docker_context/kernel/sshd_config +130 -0
- dr_environment/recipe/templates/docker_context/kernel/start_server_codespaces.sh +58 -0
- dr_environment/recipe/templates/docker_context/kernel/start_server_custom_model.sh +99 -0
- dr_environment/recipe/validate.py +158 -0
- dr_environment/recipe/versions.py +82 -0
- dr_environment-0.1.0.dist-info/METADATA +216 -0
- dr_environment-0.1.0.dist-info/RECORD +45 -0
- dr_environment-0.1.0.dist-info/WHEEL +4 -0
- dr_environment-0.1.0.dist-info/entry_points.txt +2 -0
- dr_environment-0.1.0.dist-info/licenses/AUTHORS +2 -0
- dr_environment-0.1.0.dist-info/licenses/LICENSE +201 -0
- dr_environment-0.1.0.dist-info/licenses/NOTICE +43 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
dr_environment/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""CLI entrypoint for dr-environment plugin."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from dr_environment.recipe import build
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _package_version() -> str:
|
|
30
|
+
try:
|
|
31
|
+
return version("dr-environment")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
return "0.0.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_manifest() -> None:
|
|
37
|
+
manifest = {
|
|
38
|
+
"name": "environment",
|
|
39
|
+
"version": _package_version(),
|
|
40
|
+
"description": "Build execution environment Docker contexts with offline dependency caches",
|
|
41
|
+
"authentication": False,
|
|
42
|
+
}
|
|
43
|
+
print(json.dumps(manifest, indent=2))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group()
|
|
47
|
+
@click.version_option(_package_version(), "--version")
|
|
48
|
+
def cli() -> None:
|
|
49
|
+
"""Build DataRobot execution environment Docker contexts."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command("recipe")
|
|
53
|
+
@click.option(
|
|
54
|
+
"--recipe-path",
|
|
55
|
+
default=".",
|
|
56
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
57
|
+
help="Recipe root directory, defaults to the current working directory",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--target",
|
|
61
|
+
default="docker_context",
|
|
62
|
+
help="Output directory relative to the current working directory, defaults to docker_context",
|
|
63
|
+
)
|
|
64
|
+
@click.option("--no-tarball", is_flag=True, help="Skip docker_context.tar.gz creation")
|
|
65
|
+
def recipe_cmd(recipe_path: Path, target: str, no_tarball: bool) -> None:
|
|
66
|
+
"""Build the recipe Codespace execution environment docker context."""
|
|
67
|
+
docker_context = build(
|
|
68
|
+
recipe_path.resolve(),
|
|
69
|
+
Path(target),
|
|
70
|
+
tarball=not no_tarball,
|
|
71
|
+
)
|
|
72
|
+
click.echo(f"Built docker context: {docker_context}")
|
|
73
|
+
archive = docker_context.parent / "docker_context.tar.gz"
|
|
74
|
+
if archive.is_file():
|
|
75
|
+
click.echo(f"Archive: {archive}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
if "--dr-plugin-manifest" in sys.argv:
|
|
80
|
+
print_manifest()
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
cli(prog_name="dr-environment")
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
click.echo(f"Error: {exc}", err=True)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Recipe docker context builder."""
|
|
16
|
+
|
|
17
|
+
from dr_environment.recipe.build import build
|
|
18
|
+
|
|
19
|
+
__all__ = ["build"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Orchestrate docker context build."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import tarfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from dr_environment.recipe.cache.stages import write_component_cache_fragments
|
|
26
|
+
from dr_environment.recipe.discover import discover_components
|
|
27
|
+
from dr_environment.recipe.hooks import run_environment_hook
|
|
28
|
+
from dr_environment.recipe.layout import layout_components
|
|
29
|
+
from dr_environment.recipe.models import ComponentStrategy
|
|
30
|
+
from dr_environment.recipe.render import (
|
|
31
|
+
assemble_dockerfile,
|
|
32
|
+
copy_fragment_assets,
|
|
33
|
+
render_base_fragment,
|
|
34
|
+
render_build_deps_fragment,
|
|
35
|
+
render_kernel_setup_fragment,
|
|
36
|
+
render_offline_fragment,
|
|
37
|
+
render_user_fragment,
|
|
38
|
+
render_versions_fragment,
|
|
39
|
+
)
|
|
40
|
+
from dr_environment.recipe.validate import inspect_component, validate_all
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_versions(versions_file: Path) -> dict:
|
|
44
|
+
if not versions_file.is_file():
|
|
45
|
+
return {}
|
|
46
|
+
return yaml.safe_load(versions_file.read_text(encoding="utf-8")) or {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build(
|
|
50
|
+
recipe_path: Path,
|
|
51
|
+
target: Path,
|
|
52
|
+
*,
|
|
53
|
+
tarball: bool = True,
|
|
54
|
+
) -> Path:
|
|
55
|
+
recipe_path = recipe_path.resolve()
|
|
56
|
+
docker_context = target.resolve() if target.is_absolute() else (Path.cwd() / target).resolve()
|
|
57
|
+
versions_file = recipe_path / ".datarobot/cli/versions.yaml"
|
|
58
|
+
|
|
59
|
+
components = discover_components(recipe_path)
|
|
60
|
+
validate_all(components)
|
|
61
|
+
for component in components:
|
|
62
|
+
inspect_component(component)
|
|
63
|
+
|
|
64
|
+
if docker_context.exists():
|
|
65
|
+
shutil.rmtree(docker_context)
|
|
66
|
+
docker_context.mkdir(parents=True)
|
|
67
|
+
|
|
68
|
+
versions = load_versions(versions_file)
|
|
69
|
+
copy_fragment_assets(docker_context)
|
|
70
|
+
render_base_fragment(docker_context)
|
|
71
|
+
render_user_fragment(docker_context)
|
|
72
|
+
render_versions_fragment(docker_context, versions)
|
|
73
|
+
render_build_deps_fragment(docker_context)
|
|
74
|
+
render_kernel_setup_fragment(docker_context)
|
|
75
|
+
|
|
76
|
+
for component in components:
|
|
77
|
+
if component.strategy == ComponentStrategy.HOOK:
|
|
78
|
+
run_environment_hook(component, docker_context)
|
|
79
|
+
elif component.strategy == ComponentStrategy.DEFAULT:
|
|
80
|
+
inspect_component(component)
|
|
81
|
+
layout_components([component], docker_context)
|
|
82
|
+
|
|
83
|
+
active = [c for c in components if c.strategy == ComponentStrategy.DEFAULT and c.manifests]
|
|
84
|
+
cache_stage = write_component_cache_fragments(active, docker_context)
|
|
85
|
+
|
|
86
|
+
render_offline_fragment(docker_context, cache_stage=cache_stage)
|
|
87
|
+
assemble_dockerfile(docker_context)
|
|
88
|
+
|
|
89
|
+
if tarball:
|
|
90
|
+
create_tarball(docker_context)
|
|
91
|
+
|
|
92
|
+
return docker_context
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def create_tarball(docker_context: Path) -> Path:
|
|
96
|
+
archive = docker_context.parent / "docker_context.tar.gz"
|
|
97
|
+
with tarfile.open(archive, "w:gz") as tar:
|
|
98
|
+
tar.add(docker_context, arcname=".")
|
|
99
|
+
return archive
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Cache stage fragment generators."""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Dockerfile cache stage fragment generators."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from dr_environment.recipe.layout import LOCAL_SHARED_PACKAGE
|
|
22
|
+
from dr_environment.recipe.models import Component, Ecosystem
|
|
23
|
+
|
|
24
|
+
PREVIOUS_STAGE = "kernel"
|
|
25
|
+
RUNTIME_USER = "notebooks"
|
|
26
|
+
UV_CACHE = "/opt/cache/uv"
|
|
27
|
+
NPM_CACHE = "/opt/cache/npm"
|
|
28
|
+
GO_MOD_CACHE = "/opt/cache/go/pkg/mod"
|
|
29
|
+
GO_BUILD_CACHE = "/opt/cache/go/build"
|
|
30
|
+
GO_CACHE_ROOT = "/opt/cache/go"
|
|
31
|
+
# Flat find-links directory of the exact published Python wheels/sdists. Kept separate from
|
|
32
|
+
# the uv cache: DataRobot builds the FastAPI custom application FROM this image with
|
|
33
|
+
# `uv pip install --system --no-cache`, and `--no-cache` makes uv ignore UV_CACHE. uv still
|
|
34
|
+
# honours find-links, so the offline stage points UV_FIND_LINKS here (see _python_cache_lines).
|
|
35
|
+
WHEELHOUSE = "/opt/wheelhouse"
|
|
36
|
+
|
|
37
|
+
# Paths copied from the final cache stage into the offline runtime image.
|
|
38
|
+
CACHE_COPY_PATHS = (
|
|
39
|
+
UV_CACHE,
|
|
40
|
+
NPM_CACHE,
|
|
41
|
+
GO_CACHE_ROOT,
|
|
42
|
+
WHEELHOUSE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_component_cache_fragment(
|
|
47
|
+
component: Component,
|
|
48
|
+
docker_context: Path,
|
|
49
|
+
*,
|
|
50
|
+
previous_stage: str,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Write cache fragment for component; return new stage name."""
|
|
53
|
+
stage_name = f"cache-{component.name}"
|
|
54
|
+
lines: list[str] = [f"FROM {previous_stage} AS {stage_name}"]
|
|
55
|
+
|
|
56
|
+
for info in component.manifests:
|
|
57
|
+
if info.ecosystem == Ecosystem.PYTHON:
|
|
58
|
+
lines.extend(_python_cache_lines(component.name))
|
|
59
|
+
elif info.ecosystem == Ecosystem.NPM:
|
|
60
|
+
lines.extend(_npm_cache_lines(component.name))
|
|
61
|
+
elif info.ecosystem == Ecosystem.GO:
|
|
62
|
+
lines.extend(_go_cache_lines(component.name))
|
|
63
|
+
|
|
64
|
+
fragment_path = (
|
|
65
|
+
docker_context
|
|
66
|
+
/ "dockerfile.d"
|
|
67
|
+
/ f"{component.fragment_order:02d}-cache-{component.name}.fragment"
|
|
68
|
+
)
|
|
69
|
+
fragment_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
fragment_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
71
|
+
return stage_name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write_component_cache_fragments(
|
|
75
|
+
components: list[Component],
|
|
76
|
+
docker_context: Path,
|
|
77
|
+
) -> str | None:
|
|
78
|
+
"""Write all default component cache fragments; return final cache stage name."""
|
|
79
|
+
previous = PREVIOUS_STAGE
|
|
80
|
+
wrote = False
|
|
81
|
+
for component in components:
|
|
82
|
+
if not component.manifests:
|
|
83
|
+
continue
|
|
84
|
+
wrote = True
|
|
85
|
+
previous = write_component_cache_fragment(
|
|
86
|
+
component, docker_context, previous_stage=previous
|
|
87
|
+
)
|
|
88
|
+
return previous if wrote else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _copy_component_tree(component_name: str) -> str:
|
|
92
|
+
return (
|
|
93
|
+
f"COPY --chown={RUNTIME_USER}:{RUNTIME_USER} "
|
|
94
|
+
f"components/{component_name}/ /tmp/cache-work/{component_name}/"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _python_cache_lines(component_name: str) -> list[str]:
|
|
99
|
+
sync_flags = "--frozen --no-install-project --all-extras --all-groups"
|
|
100
|
+
if component_name != LOCAL_SHARED_PACKAGE:
|
|
101
|
+
sync_flags += f" --no-install-package {LOCAL_SHARED_PACKAGE}"
|
|
102
|
+
|
|
103
|
+
warm_venv = f"/tmp/uv-cache-warm-{component_name}"
|
|
104
|
+
wheelhouse_req = f"/tmp/wheelhouse-req-{component_name}.txt"
|
|
105
|
+
return [
|
|
106
|
+
f"ENV UV_CACHE_DIR={UV_CACHE}",
|
|
107
|
+
_copy_component_tree(component_name),
|
|
108
|
+
f"WORKDIR /tmp/cache-work/{component_name}",
|
|
109
|
+
# Warm the uv cache; agent/MCP custom models install from it at runtime with
|
|
110
|
+
# `uv sync` (see the start_server.sh block in the offline stage). Mirror the same
|
|
111
|
+
# dependencies into the shared wheelhouse in the same RUN (see WHEELHOUSE above for
|
|
112
|
+
# why DataRobot's `--no-cache` app build cannot read the uv cache): `pip download`
|
|
113
|
+
# fetches the exact published artifacts, so their hashes match the ones `uv export`
|
|
114
|
+
# writes into the requirements file at deploy time; rebuilt wheels would not. Export
|
|
115
|
+
# exactly what DataRobot installs: production dependencies, project and workspace
|
|
116
|
+
# members excluded. warm_venv must be removed in this same RUN — splitting the sync
|
|
117
|
+
# and the rm across RUNs would bake the multi-GB venv into the earlier layer for good.
|
|
118
|
+
# No --only-binary=:all: here (unlike the copier/build-deps wheelhouse downloads):
|
|
119
|
+
# components pull in arbitrary packages we don't control, and plenty of legitimate
|
|
120
|
+
# pure-Python packages have stopped publishing wheels without being any less safe to
|
|
121
|
+
# build offline (e.g. rouge-score >=0.0.7 is sdist-only but pure Python, needing only
|
|
122
|
+
# the setuptools we already bake in) — rejecting all sdists here would fail builds
|
|
123
|
+
# over packages that build fine, not just the genuinely risky ones.
|
|
124
|
+
f"RUN UV_PROJECT_ENVIRONMENT={warm_venv} \\",
|
|
125
|
+
f" uv sync {sync_flags} \\",
|
|
126
|
+
" --python ${VENV_PATH}/bin/python \\",
|
|
127
|
+
f" && uv pip install --no-cache --python {warm_venv}/bin/python pip \\",
|
|
128
|
+
" && uv export --frozen --no-dev --no-emit-local --no-emit-project \\",
|
|
129
|
+
f" -o {wheelhouse_req} \\",
|
|
130
|
+
f" && {warm_venv}/bin/python -m pip download --no-deps --no-cache-dir \\",
|
|
131
|
+
f" -r {wheelhouse_req} --dest {WHEELHOUSE} \\",
|
|
132
|
+
f" && rm -rf {warm_venv} {wheelhouse_req}",
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _npm_cache_lines(component_name: str) -> list[str]:
|
|
137
|
+
return [
|
|
138
|
+
f"ENV NPM_CONFIG_CACHE={NPM_CACHE}",
|
|
139
|
+
_copy_component_tree(component_name),
|
|
140
|
+
f"WORKDIR /tmp/cache-work/{component_name}",
|
|
141
|
+
f"RUN npm ci --include=dev --cache {NPM_CACHE}",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _go_cache_lines(component_name: str) -> list[str]:
|
|
146
|
+
return [
|
|
147
|
+
f"ENV GOMODCACHE={GO_MOD_CACHE} GOCACHE={GO_BUILD_CACHE}",
|
|
148
|
+
_copy_component_tree(component_name),
|
|
149
|
+
f"WORKDIR /tmp/cache-work/{component_name}",
|
|
150
|
+
"RUN go mod download all",
|
|
151
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Discover recipe components from the root Taskfile."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from dr_environment.recipe.manifests import has_any_manifest
|
|
24
|
+
from dr_environment.recipe.models import Component, ComponentStrategy
|
|
25
|
+
|
|
26
|
+
TASKFILE_NAMES = ("Taskfile.yml", "Taskfile.yaml")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_taskfile(directory: Path) -> Path | None:
|
|
30
|
+
for name in TASKFILE_NAMES:
|
|
31
|
+
candidate = directory / name
|
|
32
|
+
if candidate.is_file():
|
|
33
|
+
return candidate
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def has_environment_task(taskfile: Path) -> bool:
|
|
38
|
+
data = yaml.safe_load(taskfile.read_text(encoding="utf-8")) or {}
|
|
39
|
+
tasks = data.get("tasks") or {}
|
|
40
|
+
return "environment" in tasks
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def discover_components(
|
|
44
|
+
recipe_path: Path,
|
|
45
|
+
*,
|
|
46
|
+
component_filter: set[str] | None = None,
|
|
47
|
+
skip_hooks: bool = False,
|
|
48
|
+
) -> list[Component]:
|
|
49
|
+
"""Return components from root Taskfile includes."""
|
|
50
|
+
root_taskfile = find_taskfile(recipe_path)
|
|
51
|
+
if root_taskfile is None:
|
|
52
|
+
raise FileNotFoundError(f"No Taskfile.yml found in {recipe_path}")
|
|
53
|
+
|
|
54
|
+
data = yaml.safe_load(root_taskfile.read_text(encoding="utf-8")) or {}
|
|
55
|
+
includes = data.get("includes") or {}
|
|
56
|
+
|
|
57
|
+
components: list[Component] = []
|
|
58
|
+
order = 10
|
|
59
|
+
for name, spec in includes.items():
|
|
60
|
+
if not isinstance(spec, dict):
|
|
61
|
+
continue
|
|
62
|
+
if spec.get("internal"):
|
|
63
|
+
continue
|
|
64
|
+
if component_filter and name not in component_filter:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
component_dir = Path(spec.get("dir", name))
|
|
68
|
+
if not component_dir.is_absolute():
|
|
69
|
+
component_dir = (recipe_path / component_dir).resolve()
|
|
70
|
+
|
|
71
|
+
taskfile = find_taskfile(component_dir)
|
|
72
|
+
strategy = ComponentStrategy.DEFAULT
|
|
73
|
+
if not skip_hooks and taskfile and has_environment_task(taskfile):
|
|
74
|
+
strategy = ComponentStrategy.HOOK
|
|
75
|
+
elif not has_any_manifest(component_dir):
|
|
76
|
+
strategy = ComponentStrategy.SKIP
|
|
77
|
+
|
|
78
|
+
components.append(
|
|
79
|
+
Component(
|
|
80
|
+
name=name,
|
|
81
|
+
source_dir=component_dir,
|
|
82
|
+
strategy=strategy,
|
|
83
|
+
fragment_order=order,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
order += 1
|
|
87
|
+
|
|
88
|
+
return components
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Invoke component Taskfile environment hooks."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from dr_environment.recipe.discover import find_taskfile
|
|
24
|
+
from dr_environment.recipe.models import Component
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_environment_hook(component: Component, docker_context: Path) -> None:
|
|
28
|
+
"""Run `task environment` with the documented env contract."""
|
|
29
|
+
taskfile = find_taskfile(component.source_dir)
|
|
30
|
+
if taskfile is None:
|
|
31
|
+
raise FileNotFoundError(f"No Taskfile for component {component.name}")
|
|
32
|
+
|
|
33
|
+
fragment_path = (
|
|
34
|
+
docker_context
|
|
35
|
+
/ "dockerfile.d"
|
|
36
|
+
/ f"{component.fragment_order:02d}-cache-{component.name}.fragment"
|
|
37
|
+
)
|
|
38
|
+
fragment_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
if not fragment_path.exists():
|
|
40
|
+
fragment_path.write_text("", encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
env = os.environ.copy()
|
|
43
|
+
env.update(
|
|
44
|
+
{
|
|
45
|
+
"DOCKER_CONTEXT": str(docker_context.resolve()),
|
|
46
|
+
"COMPONENT_DIR": str(component.source_dir.resolve()),
|
|
47
|
+
"COMPONENT_NAME": component.name,
|
|
48
|
+
"DOCKERFILE_FRAGMENT": str(fragment_path.resolve()),
|
|
49
|
+
"COMPONENT_DEST": str((docker_context / "components" / component.name).resolve()),
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
subprocess.run(
|
|
54
|
+
["task", "-d", str(component.source_dir), "environment"],
|
|
55
|
+
env=env,
|
|
56
|
+
check=True,
|
|
57
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 DataRobot, Inc. and its affiliates.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Copy component manifests into docker context."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from dr_environment.recipe.models import Component, ComponentStrategy, Ecosystem
|
|
24
|
+
|
|
25
|
+
# Recipe convention: sibling `core/` is a symlinked local shared Python package.
|
|
26
|
+
LOCAL_SHARED_PACKAGE = "core"
|
|
27
|
+
|
|
28
|
+
COPY_MAP: dict[Ecosystem, tuple[str, ...]] = {
|
|
29
|
+
Ecosystem.PYTHON: ("pyproject.toml", "uv.lock"),
|
|
30
|
+
Ecosystem.NPM: ("package.json", "package-lock.json"),
|
|
31
|
+
Ecosystem.GO: ("go.mod", "go.sum"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def strip_local_shared_python_package(pyproject_text: str) -> str:
|
|
36
|
+
"""Remove the symlinked local `core` package from a copied pyproject.toml."""
|
|
37
|
+
text = re.sub(
|
|
38
|
+
rf'^{LOCAL_SHARED_PACKAGE} = \{{ path = "{LOCAL_SHARED_PACKAGE}"[^\n]*\n',
|
|
39
|
+
"",
|
|
40
|
+
pyproject_text,
|
|
41
|
+
flags=re.MULTILINE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def _clean_inline_deps(match: re.Match[str]) -> str:
|
|
45
|
+
inner = match.group(1)
|
|
46
|
+
parts = [part.strip().strip('"') for part in inner.split(",") if part.strip()]
|
|
47
|
+
kept = [part for part in parts if part != LOCAL_SHARED_PACKAGE]
|
|
48
|
+
quoted = ", ".join(f'"{part}"' for part in kept)
|
|
49
|
+
return f"dependencies = [{quoted}]"
|
|
50
|
+
|
|
51
|
+
text = re.sub(
|
|
52
|
+
r"^dependencies = \[(.*?)\]\s*$",
|
|
53
|
+
_clean_inline_deps,
|
|
54
|
+
text,
|
|
55
|
+
flags=re.MULTILINE,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
lines = text.splitlines()
|
|
59
|
+
out: list[str] = []
|
|
60
|
+
in_dependencies = False
|
|
61
|
+
|
|
62
|
+
for line in lines:
|
|
63
|
+
stripped = line.strip()
|
|
64
|
+
if stripped == "dependencies = [":
|
|
65
|
+
in_dependencies = True
|
|
66
|
+
elif in_dependencies and stripped == "]":
|
|
67
|
+
in_dependencies = False
|
|
68
|
+
elif in_dependencies and re.fullmatch(r'"core",?', stripped):
|
|
69
|
+
continue
|
|
70
|
+
out.append(line)
|
|
71
|
+
|
|
72
|
+
trailing_newline = "\n" if pyproject_text.endswith("\n") else ""
|
|
73
|
+
return "\n".join(out) + trailing_newline
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def copy_component(component: Component, docker_context: Path) -> Path:
|
|
77
|
+
"""Copy manifest files for a component into components/<name>/."""
|
|
78
|
+
dest = docker_context / "components" / component.dest_name
|
|
79
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
for info in component.manifests:
|
|
82
|
+
manifest_dir = info.manifest.parent
|
|
83
|
+
for filename in COPY_MAP[info.ecosystem]:
|
|
84
|
+
src = manifest_dir / filename
|
|
85
|
+
if not src.is_file():
|
|
86
|
+
continue
|
|
87
|
+
dest_file = dest / filename
|
|
88
|
+
if (
|
|
89
|
+
info.ecosystem == Ecosystem.PYTHON
|
|
90
|
+
and filename == "pyproject.toml"
|
|
91
|
+
and component.name != LOCAL_SHARED_PACKAGE
|
|
92
|
+
):
|
|
93
|
+
dest_file.write_text(
|
|
94
|
+
strip_local_shared_python_package(src.read_text(encoding="utf-8")),
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
shutil.copy2(src, dest_file)
|
|
99
|
+
|
|
100
|
+
return dest
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def layout_components(components: list[Component], docker_context: Path) -> None:
|
|
104
|
+
for component in components:
|
|
105
|
+
if component.strategy != ComponentStrategy.DEFAULT:
|
|
106
|
+
continue
|
|
107
|
+
if not component.manifests:
|
|
108
|
+
continue
|
|
109
|
+
copy_component(component, docker_context)
|