Skip to content

Commit f367143

Browse files
Merge pull request #1914 from basetenlabs/bump-version-0.11.0
Release 0.11.0
2 parents 3c224b9 + 4e17d52 commit f367143

File tree

17 files changed

+561
-141
lines changed

17 files changed

+561
-141
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "truss"
3-
version = "0.10.13"
3+
version = "0.11.0"
44
description = "A seamless bridge from model development to model delivery"
55
authors = [
66
{ name = "Pankaj Gupta", email = "no-reply@baseten.co" },

truss/cli/train/core.py

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
import tarfile
34
import tempfile
45
from dataclasses import dataclass
@@ -16,6 +17,11 @@
1617
from truss.cli.train.types import PrepareCheckpointArgs, PrepareCheckpointResult
1718
from truss.cli.utils import common as cli_common
1819
from truss.cli.utils.output import console
20+
from truss.remote.baseten.custom_types import (
21+
FileSummary,
22+
FileSummaryWithTotalSize,
23+
GetCacheSummaryResponseV1,
24+
)
1925
from truss.remote.baseten.remote import BasetenRemote
2026
from truss_train import loader
2127
from truss_train.definitions import DeployCheckpointsConfig
@@ -446,6 +452,44 @@ def fetch_project_by_name_or_id(
446452
raise click.ClickException(f"Error fetching project: {str(e)}")
447453

448454

455+
def create_file_summary_with_directory_sizes(
456+
files: list[FileSummary],
457+
) -> list[FileSummaryWithTotalSize]:
458+
directory_sizes = calculate_directory_sizes(files)
459+
return [
460+
FileSummaryWithTotalSize(
461+
file_summary=file_info,
462+
total_size=directory_sizes.get(file_info.path, file_info.size_bytes),
463+
)
464+
for file_info in files
465+
]
466+
467+
468+
def calculate_directory_sizes(
469+
files: list[FileSummary], max_depth: int = 100
470+
) -> dict[str, int]:
471+
directory_sizes = {}
472+
473+
for file_info in files:
474+
if file_info.file_type == "directory":
475+
directory_sizes[file_info.path] = 0
476+
477+
for file_info in files:
478+
current_path = file_info.path
479+
for i in range(max_depth):
480+
if current_path is None:
481+
break
482+
if current_path in directory_sizes:
483+
directory_sizes[current_path] += file_info.size_bytes
484+
# Move to parent directory
485+
parent = os.path.dirname(current_path)
486+
if parent == current_path: # Reached root
487+
break
488+
current_path = parent
489+
490+
return directory_sizes
491+
492+
449493
def view_cache_summary(
450494
remote_provider: BasetenRemote,
451495
project_id: str,
@@ -454,71 +498,63 @@ def view_cache_summary(
454498
):
455499
"""View cache summary for a training project."""
456500
try:
457-
cache_data = remote_provider.api.get_cache_summary(project_id)
501+
raw_cache_data = remote_provider.api.get_cache_summary(project_id)
458502

459-
if not cache_data:
503+
if not raw_cache_data:
460504
console.print("No cache summary found for this project.", style="yellow")
461505
return
462506

507+
cache_data = GetCacheSummaryResponseV1.model_validate(raw_cache_data)
508+
463509
table = rich.table.Table(title=f"Cache summary for project: {project_id}")
464510
table.add_column("File Path", style="cyan")
465511
table.add_column("Size", style="green")
466512
table.add_column("Modified", style="yellow")
467513
table.add_column("Type")
468514
table.add_column("Permissions", style="magenta")
469515

470-
files = cache_data.get("file_summaries", [])
516+
files = cache_data.file_summaries
471517
if not files:
472518
console.print("No files found in cache.", style="yellow")
473519
return
474520

475-
reverse = order == SORT_ORDER_DESC
521+
files_with_total_sizes = create_file_summary_with_directory_sizes(files)
476522

477-
if sort_by == SORT_BY_FILEPATH:
478-
files.sort(key=lambda x: x.get("path", ""), reverse=reverse)
479-
elif sort_by == SORT_BY_SIZE:
480-
files.sort(key=lambda x: x.get("size_bytes", 0), reverse=reverse)
481-
elif sort_by == SORT_BY_MODIFIED:
482-
files.sort(key=lambda x: x.get("modified", ""), reverse=reverse)
483-
elif sort_by == SORT_BY_TYPE:
484-
files.sort(key=lambda x: x.get("file_type", ""), reverse=reverse)
485-
elif sort_by == SORT_BY_PERMISSIONS:
486-
files.sort(key=lambda x: x.get("permissions", ""), reverse=reverse)
487-
488-
total_size = 0
489-
for file_info in files:
490-
total_size += file_info.get("size_bytes", 0)
523+
reverse = order == SORT_ORDER_DESC
524+
sort_key = _get_sort_key(sort_by)
525+
files_with_total_sizes.sort(key=sort_key, reverse=reverse)
491526

527+
total_size = sum(
528+
file_info.file_summary.size_bytes for file_info in files_with_total_sizes
529+
)
492530
total_size_str = common.format_bytes_to_human_readable(total_size)
493531

494532
console.print(
495-
f"📅 Cache captured at: {cache_data.get('timestamp', 'Unknown')}",
496-
style="bold blue",
533+
f"📅 Cache captured at: {cache_data.timestamp}", style="bold blue"
497534
)
535+
console.print(f"📁 Project ID: {cache_data.project_id}", style="bold blue")
536+
console.print()
498537
console.print(
499-
f"📁 Project ID: {cache_data.get('project_id', 'Unknown')}",
500-
style="bold blue",
538+
f"📊 Total files: {len(files_with_total_sizes)}", style="bold green"
501539
)
502-
console.print()
503-
console.print(f"📊 Total files: {len(files)}", style="bold green")
504540
console.print(f"💾 Total size: {total_size_str}", style="bold green")
505541
console.print()
506542

507-
for file_info in files:
508-
size_bytes = file_info.get("size_bytes", 0)
543+
for file_info in files_with_total_sizes:
544+
total_size = file_info.total_size
509545

510-
size_str = cli_common.format_bytes_to_human_readable(int(size_bytes))
546+
size_str = cli_common.format_bytes_to_human_readable(int(total_size))
511547

512548
modified_str = cli_common.format_localized_time(
513-
file_info.get("modified", "Unknown")
549+
file_info.file_summary.modified
514550
)
515551

516552
table.add_row(
517-
file_info.get("path", "Unknown"),
553+
file_info.file_summary.path,
518554
size_str,
519555
modified_str,
520-
file_info.get("file_type", "Unknown"),
521-
file_info.get("permissions", "Unknown"),
556+
file_info.file_summary.file_type or "Unknown",
557+
file_info.file_summary.permissions or "Unknown",
522558
)
523559

524560
console.print(table)
@@ -528,6 +564,21 @@ def view_cache_summary(
528564
raise
529565

530566

567+
def _get_sort_key(sort_by: str) -> Callable[[FileSummaryWithTotalSize], Any]:
568+
if sort_by == SORT_BY_FILEPATH:
569+
return lambda x: x.file_summary.path
570+
elif sort_by == SORT_BY_SIZE:
571+
return lambda x: x.total_size
572+
elif sort_by == SORT_BY_MODIFIED:
573+
return lambda x: x.file_summary.modified
574+
elif sort_by == SORT_BY_TYPE:
575+
return lambda x: x.file_summary.file_type or ""
576+
elif sort_by == SORT_BY_PERMISSIONS:
577+
return lambda x: x.file_summary.permissions or ""
578+
else:
579+
raise ValueError(f"Invalid --sort argument: {sort_by}")
580+
581+
531582
def view_cache_summary_by_project(
532583
remote_provider: BasetenRemote,
533584
project_identifier: str,

truss/contexts/image_builder/serving_image_builder.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import os
56
import re
67
import shutil
78
from abc import ABC, abstractmethod
@@ -783,6 +784,10 @@ def _render_dockerfile(
783784
config
784785
)
785786

787+
non_root_user = os.getenv("BT_USE_NON_ROOT_USER", False)
788+
enable_model_container_admin_commands = os.getenv(
789+
"BT_ENABLE_MODEL_CONTAINER_ADMIN_CMDS"
790+
)
786791
dockerfile_contents = dockerfile_template.render(
787792
should_install_server_requirements=should_install_server_requirements,
788793
base_image_name_and_tag=base_image_name_and_tag,
@@ -816,6 +821,8 @@ def _render_dockerfile(
816821
build_commands=build_commands,
817822
use_local_src=config.use_local_src,
818823
passthrough_environment_variables=passthrough_environment_variables,
824+
non_root_user=non_root_user,
825+
enable_model_container_admin_commands=enable_model_container_admin_commands,
819826
**FILENAME_CONSTANTS_MAP,
820827
)
821828
# Consolidate repeated empty lines to single empty lines.

truss/contexts/local_loader/docker_build_emulator.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from dataclasses import dataclass, field
23
from pathlib import Path
34
from typing import Dict, List
@@ -31,12 +32,32 @@ def __init__(self, dockerfile_path: Path, context_dir: Path) -> None:
3132
self._context_dir = context_dir
3233

3334
def run(self, fs_root_dir: Path) -> DockerBuildEmulatorResult:
34-
def _resolve_env(key: str) -> str:
35-
if key.startswith("$"):
36-
key = key.replace("$", "", 1)
37-
v = result.env[key]
38-
return v
39-
return key
35+
def _resolve_env(in_value: str) -> str:
36+
# Valid environment variable name pattern
37+
var_name_pattern = r"[A-Za-z_][A-Za-z0-9_]*"
38+
39+
# Handle ${VAR} syntax
40+
def replace_braced_var(match):
41+
var_name = match.group(1)
42+
return result.env.get(
43+
var_name, match.group(0)
44+
) # Return original if not found
45+
46+
# Handle $VAR syntax (word boundary ensures we don't match parts of other vars)
47+
def replace_simple_var(match):
48+
var_name = match.group(1)
49+
return result.env.get(
50+
var_name, match.group(0)
51+
) # Return original if not found
52+
53+
# Replace ${VAR} patterns first, using % substitution to avoid additional braces noise with f-strings
54+
value = re.sub(
55+
r"\$\{(%s)\}" % var_name_pattern, replace_braced_var, in_value
56+
)
57+
# Then replace remaining $VAR patterns (only at word boundaries)
58+
value = re.sub(r"\$(%s)\b" % var_name_pattern, replace_simple_var, value)
59+
60+
return value
4061

4162
def _resolve_values(keys: List[str]) -> List[str]:
4263
return list(map(_resolve_env, keys))
@@ -53,11 +74,14 @@ def _resolve_values(keys: List[str]) -> List[str]:
5374
if cmd.instruction == DockerInstruction.ENTRYPOINT:
5475
result.entrypoint = list(values)
5576
if cmd.instruction == DockerInstruction.COPY:
77+
# Filter out --chown flags
78+
filtered_values = [v for v in values if not v.startswith("--chown")]
79+
5680
# NB(nikhil): Skip COPY commands with --from flag (multi-stage builds)
57-
if len(values) != 2:
81+
if len(filtered_values) != 2:
5882
continue
5983

60-
src, dst = values
84+
src, dst = filtered_values
6185
src = src.replace("./", "", 1)
6286
dst = dst.replace("/", "", 1)
6387
copy_tree_or_file(self._context_dir / src, fs_root_dir / dst)

truss/remote/baseten/custom_types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ class FileSummary(pydantic.BaseModel):
138138
)
139139

140140

141+
class FileSummaryWithTotalSize(pydantic.BaseModel):
142+
file_summary: FileSummary
143+
total_size: int = pydantic.Field(
144+
description="Total size of the file and all its subdirectories"
145+
)
146+
147+
141148
class GetCacheSummaryResponseV1(pydantic.BaseModel):
142149
"""Response for getting cache summary."""
143150

truss/templates/base.Dockerfile.jinja

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,35 @@ FROM {{ base_image_name_and_tag }} AS truss_server
88
{%- set python_executable = config.base_image.python_executable_path or 'python3' %}
99
ENV PYTHON_EXECUTABLE="{{ python_executable }}"
1010

11+
{%- set app_username = "app" %} {# needed later for USER directive#}
12+
{% block user_setup %}
13+
{%- set app_user_uid = 60000 %}
14+
{%- set control_server_dir = "/control" %}
15+
{%- set default_owner = "root:root" %}
16+
{# The non-root user's home directory. #}
17+
{# uv will use $HOME to install packages. #}
18+
ENV HOME=/home/{{ app_username }}
19+
{# Directory containing inference server code. #}
20+
ENV APP_HOME=/{{ app_username }}
21+
RUN mkdir -p ${APP_HOME} {{ control_server_dir }}
22+
{# Create a non-root user to run model containers. #}
23+
RUN useradd -u {{ app_user_uid }} -ms /bin/bash {{ app_username }}
24+
{% endblock %} {#- endblock user_setup #}
25+
26+
{#- at the very beginning, set non-interactive mode for apt #}
27+
ENV DEBIAN_FRONTEND=noninteractive
28+
29+
{# If non-root user is enabled and model container admin commands are enabled, install sudo #}
30+
{# to allow the non-root user to install packages. #}
31+
{%- if non_root_user and enable_model_container_admin_commands %}
32+
RUN apt update && apt install -y sudo
33+
{%- set allowed_admin_commands = ["/usr/bin/apt install *", "/usr/bin/apt update"] %}
34+
RUN echo "Defaults:{{ app_username }} passwd_tries=0\n{{ app_username }} ALL=(root) NOPASSWD: {{ allowed_admin_commands | join(", ") }}" > /etc/sudoers.d/app-packages
35+
RUN chmod 0440 /etc/sudoers.d/app-packages
36+
{#- optional but good practice: check if the sudoers file is valid #}
37+
RUN visudo -c
38+
{%- endif %} {#- endif non_root_user and enable_model_container_admin_commands #}
39+
1140
{%- set UV_VERSION = "0.7.19" %}
1241
{#
1342
NB(nikhil): We use a semi-complex uv installation command across the board:
@@ -39,7 +68,8 @@ RUN if ! command -v uv >/dev/null 2>&1; then \
3968
command -v curl >/dev/null 2>&1 || (apt update && apt install -y curl) && \
4069
curl -LsSf --retry 5 --retry-delay 5 https://astral.sh/uv/{{ UV_VERSION }}/install.sh | sh; \
4170
fi
42-
ENV PATH="/root/.local/bin:$PATH"
71+
{# Add the user's local bin to the path, used by uv. #}
72+
ENV PATH=${PATH}:${HOME}/.local/bin
4373
{% endblock %}
4474

4575
{% block base_image_patch %}
@@ -57,7 +87,7 @@ RUN {{ sys_pip_install_command }} install mkl
5787

5888
{% block install_system_requirements %}
5989
{%- if should_install_system_requirements %}
60-
COPY ./{{ system_packages_filename }} {{ system_packages_filename }}
90+
COPY --chown={{ default_owner }} ./{{ system_packages_filename }} {{ system_packages_filename }}
6191
RUN apt-get update && apt-get install --yes --no-install-recommends $(cat {{ system_packages_filename }}) \
6292
&& apt-get autoremove -y \
6393
&& apt-get clean -y \
@@ -68,19 +98,18 @@ RUN apt-get update && apt-get install --yes --no-install-recommends $(cat {{ sys
6898

6999
{% block install_requirements %}
70100
{%- if should_install_user_requirements_file %}
71-
COPY ./{{ user_supplied_requirements_filename }} {{ user_supplied_requirements_filename }}
101+
COPY --chown={{ default_owner }} ./{{ user_supplied_requirements_filename }} {{ user_supplied_requirements_filename }}
72102
RUN {{ sys_pip_install_command }} -r {{ user_supplied_requirements_filename }} --no-cache-dir
73103
{%- endif %}
74104
{%- if should_install_requirements %}
75-
COPY ./{{ config_requirements_filename }} {{ config_requirements_filename }}
105+
COPY --chown={{ default_owner }} ./{{ config_requirements_filename }} {{ config_requirements_filename }}
76106
RUN {{ sys_pip_install_command }} -r {{ config_requirements_filename }} --no-cache-dir
77107
{%- endif %}
78108
{% endblock %}
79109

80110

81111

82112
{%- if not config.docker_server %}
83-
ENV APP_HOME="/app"
84113
WORKDIR $APP_HOME
85114
{%- endif %}
86115

@@ -90,7 +119,7 @@ WORKDIR $APP_HOME
90119

91120
{% block bundled_packages_copy %}
92121
{%- if bundled_packages_dir_exists %}
93-
COPY ./{{ config.bundled_packages_dir }} /packages
122+
COPY --chown={{ default_owner }} ./{{ config.bundled_packages_dir }} /packages
94123
{%- endif %}
95124
{% endblock %}
96125

0 commit comments

Comments
 (0)