Skip to content

Commit 0460e57

Browse files
authored
feat: support pack_git_packages and fix add group for alpine (#5295)
* feat: pack git packages Signed-off-by: Frost Ming <me@frostming.com> * fix: add group command for alpine Signed-off-by: Frost Ming <me@frostming.com> --------- Signed-off-by: Frost Ming <me@frostming.com>
1 parent 36ea2b8 commit 0460e57

File tree

4 files changed

+149
-100
lines changed

4 files changed

+149
-100
lines changed

src/_bentoml_sdk/images.py

Lines changed: 124 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pathlib import Path
1010

1111
import attrs
12+
import fs
1213

1314
from bentoml._internal.bento.bento import ImageInfo
1415
from bentoml._internal.bento.build_config import BentoBuildConfig
@@ -18,10 +19,14 @@
1819
from bentoml._internal.configuration import get_quiet_mode
1920
from bentoml._internal.container.frontend.dockerfile import CONTAINER_METADATA
2021
from bentoml._internal.container.frontend.dockerfile import CONTAINER_SUPPORTED_DISTROS
21-
from bentoml._internal.utils.pkg import get_local_bentoml_dependency
2222
from bentoml.exceptions import BentoMLConfigException
2323
from bentoml.exceptions import BentoMLException
2424

25+
if t.TYPE_CHECKING:
26+
from fs.base import FS
27+
28+
from bentoml._internal.bento.build_config import BentoEnvSchema
29+
2530
if sys.version_info >= (3, 11):
2631
import tomllib
2732
else:
@@ -40,6 +45,7 @@ class Image:
4045
python_version: str = DEFAULT_PYTHON_VERSION
4146
commands: t.List[str] = attrs.field(factory=list)
4247
lock_python_packages: bool = True
48+
pack_git_packages: bool = True
4349
python_requirements: str = ""
4450
post_commands: t.List[str] = attrs.field(factory=list)
4551
scripts: t.Dict[str, str] = attrs.field(factory=dict, init=False)
@@ -122,80 +128,135 @@ def run_script(self, script: str) -> t.Self:
122128
self.scripts[script] = target_script
123129
return self
124130

125-
def freeze(self, platform_: str | None = None) -> ImageInfo:
131+
def freeze(
132+
self, bento_fs: FS, envs: list[BentoEnvSchema], platform_: str | None = None
133+
) -> ImageInfo:
126134
"""Freeze the image to an ImageInfo object for build."""
127-
python_requirements = self._freeze_python_requirements(platform_)
128-
return ImageInfo(
135+
python_requirements = self._freeze_python_requirements(bento_fs, platform_)
136+
from importlib import resources
137+
138+
from _bentoml_impl.docker import generate_dockerfile
139+
from bentoml._internal.utils.filesystem import copy_file_to_fs_folder
140+
141+
# Prepare env/python files
142+
py_folder = fs.path.join("env", "python")
143+
bento_fs.makedirs(py_folder, recreate=True)
144+
reqs_txt = fs.path.join(py_folder, "requirements.txt")
145+
bento_fs.writetext(reqs_txt, python_requirements)
146+
info = ImageInfo(
129147
base_image=self.base_image,
130148
python_version=self.python_version,
131-
commands=["export UV_COMPILE_BYTECODE=1", *self.commands],
149+
commands=self.commands,
132150
python_requirements=python_requirements,
133151
post_commands=self.post_commands,
134-
scripts=self.scripts,
135152
)
136-
137-
def _freeze_python_requirements(self, platform_: str | None = None) -> str:
138-
from tempfile import TemporaryDirectory
139-
153+
# Prepare env/docker files
154+
docker_folder = fs.path.join("env", "docker")
155+
bento_fs.makedirs(docker_folder, recreate=True)
156+
dockerfile_path = fs.path.join(docker_folder, "Dockerfile")
157+
bento_fs.writetext(
158+
dockerfile_path,
159+
generate_dockerfile(info, bento_fs, enable_buildkit=False, envs=envs),
160+
)
161+
for script_name, target_path in self.scripts.items():
162+
copy_file_to_fs_folder(script_name, bento_fs, dst_filename=target_path)
163+
164+
with resources.path(
165+
"bentoml._internal.container.frontend.dockerfile", "entrypoint.sh"
166+
) as entrypoint_path:
167+
copy_file_to_fs_folder(str(entrypoint_path), bento_fs, docker_folder)
168+
return info
169+
170+
def _freeze_python_requirements(
171+
self, bento_fs: FS, platform_: str | None = None
172+
) -> str:
140173
from pip_requirements_parser import RequirementsFile
141174

175+
from bentoml._internal.bento.bentoml_builder import build_bentoml_sdist
176+
from bentoml._internal.bento.build_config import PythonOptions
142177
from bentoml._internal.configuration import get_uv_command
143178

144-
with TemporaryDirectory(prefix="bento-reqs-") as parent:
145-
requirements_in = Path(parent).joinpath("requirements.in")
146-
requirements_in.write_text(self.python_requirements)
147-
# XXX: RequirementsFile.from_string() does not work due to bugs
148-
requirements_file = RequirementsFile.from_file(str(requirements_in))
149-
has_bentoml_req = any(
150-
req.name and req.name.lower() == "bentoml" and req.link is not None
151-
for req in requirements_file.requirements
179+
py_folder = fs.path.join("env", "python")
180+
bento_fs.makedirs(py_folder, recreate=True)
181+
requirements_in = Path(
182+
bento_fs.getsyspath(fs.path.join(py_folder, "requirements.in"))
183+
)
184+
requirements_in.write_text(self.python_requirements)
185+
py_req = fs.path.join("env", "python", "requirements.txt")
186+
requirements_out = Path(bento_fs.getsyspath(py_req))
187+
# XXX: RequirementsFile.from_string() does not work due to bugs
188+
requirements_file = RequirementsFile.from_file(str(requirements_in))
189+
has_bentoml_req = any(
190+
req.name and req.name.lower() == "bentoml" and req.link is not None
191+
for req in requirements_file.requirements
192+
)
193+
wheels_folder = fs.path.join("env", "python", "wheels")
194+
with requirements_in.open("w") as f:
195+
f.write(requirements_file.dumps(preserve_one_empty_line=True))
196+
if not has_bentoml_req:
197+
sdist_name = build_bentoml_sdist(bento_fs.getsyspath(wheels_folder))
198+
bento_req = get_bentoml_requirement()
199+
if bento_req is not None:
200+
logger.info(
201+
"Adding BentoML requirement to the image: %s.", bento_req
202+
)
203+
f.write(f"{bento_req}\n")
204+
elif sdist_name is not None:
205+
f.write(f"./wheels/{sdist_name}\n")
206+
if not self.lock_python_packages:
207+
requirements_out.parent.mkdir(parents=True, exist_ok=True)
208+
requirements_out.write_text(requirements_in.read_text())
209+
PythonOptions.fix_dep_urls(
210+
str(requirements_out),
211+
bento_fs.getsyspath(wheels_folder),
212+
self.pack_git_packages,
152213
)
153-
with requirements_in.open("w") as f:
154-
f.write(requirements_file.dumps(preserve_one_empty_line=True))
155-
if not has_bentoml_req:
156-
req = get_bentoml_requirement() or get_local_bentoml_dependency()
157-
f.write(f"{req}\n")
158-
if not self.lock_python_packages:
159-
return requirements_in.read_text()
160-
lock_args = [
161-
str(requirements_in),
162-
"--allow-unsafe",
163-
"--no-header",
164-
f"--output-file={requirements_in.with_suffix('.lock')}",
165-
"--emit-index-url",
166-
"--emit-find-links",
167-
"--no-annotate",
168-
]
169-
if get_debug_mode():
170-
lock_args.append("--verbose")
171-
else:
172-
lock_args.append("--quiet")
173-
logger.info("Locking PyPI package versions.")
174-
if platform_:
175-
lock_args.extend(["--python-platform", platform_])
176-
elif platform.system() != "Linux" or platform.machine() != "x86_64":
177-
logger.info(
178-
"Locking packages for %s. Pass `--platform` option to specify the platform.",
179-
DEFAULT_LOCK_PLATFORM,
180-
)
181-
lock_args.extend(["--python-platform", DEFAULT_LOCK_PLATFORM])
182-
cmd = [*get_uv_command(), "pip", "compile", *lock_args]
183-
try:
184-
subprocess.check_call(
185-
cmd,
186-
text=True,
187-
stderr=subprocess.DEVNULL if get_quiet_mode() else None,
188-
cwd=parent,
189-
)
190-
except subprocess.CalledProcessError as e:
191-
raise BentoMLException(f"Failed to lock PyPI packages: {e}") from None
192-
locked_requirements = ( # uv doesn't preserve global option lines, add them here
193-
"\n".join(option.dumps() for option in requirements_file.options)
214+
return requirements_out.read_text()
215+
lock_args = [
216+
str(requirements_in),
217+
"--allow-unsafe",
218+
"--no-header",
219+
f"--output-file={requirements_out}",
220+
"--emit-index-url",
221+
"--emit-find-links",
222+
"--no-annotate",
223+
]
224+
if get_debug_mode():
225+
lock_args.append("--verbose")
226+
else:
227+
lock_args.append("--quiet")
228+
logger.info("Locking PyPI package versions.")
229+
if platform_:
230+
lock_args.extend(["--python-platform", platform_])
231+
elif platform.system() != "Linux" or platform.machine() != "x86_64":
232+
logger.info(
233+
"Locking packages for %s. Pass `--platform` option to specify the platform.",
234+
DEFAULT_LOCK_PLATFORM,
194235
)
195-
if locked_requirements:
196-
locked_requirements += "\n"
197-
locked_requirements += requirements_in.with_suffix(".lock").read_text()
198-
return locked_requirements
236+
lock_args.extend(["--python-platform", DEFAULT_LOCK_PLATFORM])
237+
cmd = [*get_uv_command(), "pip", "compile", *lock_args]
238+
try:
239+
subprocess.check_call(
240+
cmd,
241+
text=True,
242+
stderr=subprocess.DEVNULL if get_quiet_mode() else None,
243+
cwd=bento_fs.getsyspath(py_folder),
244+
)
245+
except subprocess.CalledProcessError as e:
246+
raise BentoMLException(f"Failed to lock PyPI packages: {e}") from None
247+
locked_requirements = ( # uv doesn't preserve global option lines, add them here
248+
"\n".join(option.dumps() for option in requirements_file.options)
249+
)
250+
if locked_requirements:
251+
locked_requirements += "\n"
252+
locked_requirements += requirements_out.read_text()
253+
requirements_out.write_text(locked_requirements)
254+
PythonOptions.fix_dep_urls(
255+
str(requirements_out),
256+
bento_fs.getsyspath(wheels_folder),
257+
self.pack_git_packages,
258+
)
259+
return requirements_out.read_text()
199260

200261

201262
@attrs.define

src/bentoml/_internal/bento/bento.py

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def check_fs(self, _attr: t.Any, new_fs: FS):
161161
else:
162162
self._model_store = ModelStore(models)
163163

164-
def __init__(self, tag: Tag, bento_fs: "FS", info: "BentoInfo"):
164+
def __init__(self, tag: Tag, bento_fs: "FS", info: "BaseBentoInfo"):
165165
self._tag = tag
166166
self.__fs = bento_fs
167167
self.check_fs(None, bento_fs)
@@ -210,7 +210,8 @@ def get_manifest(self, dev: bool = False) -> BentoManifestSchema:
210210
]
211211
image: ImageInfo | None = None
212212
else:
213-
image = t.cast(ImageInfo, info.image)
213+
assert isinstance(info, BentoInfoV2)
214+
image = info.image
214215
runners = []
215216
return BentoManifestSchema(
216217
name=info.name,
@@ -440,9 +441,8 @@ def append_model(model: BentoModelInfo) -> None:
440441
),
441442
envs=build_config.envs,
442443
schema=svc.schema() if not is_legacy else {},
443-
image=image.freeze(platform),
444+
image=image.freeze(bento_fs, build_config.envs, platform),
444445
)
445-
bento_info.image.write_to_bento(bento_fs, build_config.envs)
446446

447447
res = Bento(tag, bento_fs, bento_info)
448448
if bare:
@@ -842,33 +842,6 @@ class ImageInfo:
842842
commands: t.List[str] = attr.field(factory=list)
843843
python_requirements: str = ""
844844
post_commands: t.List[str] = attr.field(factory=list)
845-
scripts: t.Dict[str, str] = attr.field(factory=dict)
846-
847-
def write_to_bento(self, bento_fs: FS, envs: list[BentoEnvSchema]) -> None:
848-
from importlib import resources
849-
850-
from _bentoml_impl.docker import generate_dockerfile
851-
852-
# Prepare env/python files
853-
py_folder = fs.path.join("env", "python")
854-
bento_fs.makedirs(py_folder, recreate=True)
855-
reqs_txt = fs.path.join(py_folder, "requirements.txt")
856-
bento_fs.writetext(reqs_txt, self.python_requirements)
857-
# Prepare env/docker files
858-
docker_folder = fs.path.join("env", "docker")
859-
bento_fs.makedirs(docker_folder, recreate=True)
860-
dockerfile_path = fs.path.join(docker_folder, "Dockerfile")
861-
bento_fs.writetext(
862-
dockerfile_path,
863-
generate_dockerfile(self, bento_fs, enable_buildkit=False, envs=envs),
864-
)
865-
for script_name, target_path in self.scripts.items():
866-
copy_file_to_fs_folder(script_name, bento_fs, dst_filename=target_path)
867-
868-
with resources.path(
869-
"bentoml._internal.container.frontend.dockerfile", "entrypoint.sh"
870-
) as entrypoint_path:
871-
copy_file_to_fs_folder(str(entrypoint_path), bento_fs, docker_folder)
872845

873846

874847
@attr.frozen(repr=False)

src/bentoml/_internal/bento/build_config.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -691,13 +691,21 @@ def write_to_bento(
691691
)
692692
except subprocess.CalledProcessError as e:
693693
raise BentoMLException(f"Failed to lock PyPI packages: {e}") from None
694-
self._fix_dep_urls(pip_compile_out, bento_fs.getsyspath(wheels_folder))
694+
self.fix_dep_urls(
695+
pip_compile_out,
696+
bento_fs.getsyspath(wheels_folder),
697+
self.pack_git_packages,
698+
)
695699
else:
696700
requirements_txt = bento_fs.getsyspath(
697701
fs.path.combine(py_folder, "requirements.txt")
698702
)
699703
if os.path.exists(requirements_txt):
700-
self._fix_dep_urls(requirements_txt, bento_fs.getsyspath(wheels_folder))
704+
self.fix_dep_urls(
705+
requirements_txt,
706+
bento_fs.getsyspath(wheels_folder),
707+
self.pack_git_packages,
708+
)
701709

702710
def with_defaults(self) -> PythonOptions:
703711
# Convert from user provided options to actual build options with default values
@@ -708,7 +716,10 @@ def with_defaults(self) -> PythonOptions:
708716
return attr.evolve(self, lock_packages=False)
709717
return self
710718

711-
def _fix_dep_urls(self, requirements_txt: str, wheels_folder: str) -> None:
719+
@staticmethod
720+
def fix_dep_urls(
721+
requirements_txt: str, wheels_folder: str, pack_git_packages: bool = True
722+
) -> None:
712723
"""Replace the git dependencies in the requirements.lock file with the
713724
paths to the local copy.
714725
"""
@@ -725,7 +736,7 @@ def _fix_dep_urls(self, requirements_txt: str, wheels_folder: str) -> None:
725736

726737
if "/env/python/wheels" in link.url:
727738
filename = link.filename
728-
elif self.pack_git_packages and link.url.startswith("git+"):
739+
elif pack_git_packages and link.url.startswith("git+"):
729740
# We are only able to handle SSH Git URLs
730741
url, ref = link.url_without_fragment[4:], ""
731742
if "@" in link.path: # ssh://git@owner/repo@ref

src/bentoml/_internal/container/frontend/dockerfile/templates/base_v2.j2

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ ENV PYTHONUNBUFFERED=1
3434
ARG BENTO_USER={{ bento__user }}
3535
ARG BENTO_USER_UID={{ bento__uid_gid }}
3636
ARG BENTO_USER_GID={{ bento__uid_gid }}
37-
RUN groupadd -g $BENTO_USER_GID -o $BENTO_USER && useradd -m -u $BENTO_USER_UID -g $BENTO_USER_GID -o -r $BENTO_USER
37+
RUN if command -v groupadd &>/dev/null; then \
38+
groupadd -g $BENTO_USER_GID -o $BENTO_USER && useradd -m -u $BENTO_USER_UID -g $BENTO_USER_GID -o -r $BENTO_USER; \
39+
else \
40+
addgroup -g $BENTO_USER_GID -S $BENTO_USER && adduser -u $BENTO_USER_UID -G $BENTO_USER -S $BENTO_USER; \
41+
fi
3842
{% endblock %}
3943

4044
{% block SETUP_BENTO_ENVARS %}
@@ -74,7 +78,7 @@ ENV UV_COMPILE_BYTECODE=1
7478

7579
COPY --chown={{ bento__user }}:{{ bento__user }} ./env/python ./env/python/
7680
# install python packages
77-
{% call common.RUN(__enable_buildkit__) -%} {{ __pip_cache__ }} {% endcall -%} uv pip install -r ./env/python/requirements.txt
81+
{% call common.RUN(__enable_buildkit__) -%} {{ __pip_cache__ }} {% endcall -%} uv --directory ./env/python/ pip install -r requirements.txt
7882

7983
{% for command in __options__post_commands %}
8084
RUN {{ command }}

0 commit comments

Comments
 (0)