Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion src/container_magic/generators/dockerfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,18 @@ def generate_dockerfile(

# Chown venv to runtime user in leaf stages so it's writable in development
if has_user and stage_name in leaf_stages and venv_active:
ordered_steps.append({"type": "venv_chown"})
# Determine if we're in a non-root context (inherited from parent)
last_become_user = None
for s in reversed(ordered_steps):
if s.get("type") == "become":
last_become_user = s.get("name")
break
inherited_context = _get_parent_user_context(stage_name, stages, user_name)
is_root = last_become_user is None and inherited_context is None
chown_step = {"type": "venv_chown", "is_root": is_root}
if not is_root:
chown_step["restore_user"] = last_become_user or inherited_context
ordered_steps.append(chown_step)

# Inject implicit become at end of leaf stages only
# Intermediate stages stay as root so child stages inherit root context
Expand Down
6 changes: 6 additions & 0 deletions src/container_magic/templates/Dockerfile.j2
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
USER {{ step.restore_user }}
{% endif %}
{% elif step.type == "venv_chown" %}
{% if not step.is_root %}
USER root
{% endif %}
RUN chown -R "${USER_UID}:${USER_GID}" /opt/venv
{% if not step.is_root %}
USER {{ step.restore_user }}
{% endif %}
{% elif step.type == "custom" %}
{% if step.command.startswith('ADD ') or step.command.startswith('ARG ') or step.command.startswith('CMD ') or step.command.startswith('COPY ') or step.command.startswith('ENTRYPOINT ') or step.command.startswith('ENV ') or step.command.startswith('EXPOSE ') or step.command.startswith('FROM ') or step.command.startswith('HEALTHCHECK ') or step.command.startswith('LABEL ') or step.command.startswith('RUN ') or step.command.startswith('SHELL ') or step.command.startswith('STOPSIGNAL ') or step.command.startswith('USER ') or step.command.startswith('VOLUME ') or step.command.startswith('WORKDIR ') %}
{{ step.command }}
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/test_pip_venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,34 @@ def test_venv_chown_after_all_pip_steps(self):
assert len(chown_lines) >= 1
assert chown_lines[0] > pip_lines[-1]

def test_venv_chown_switches_to_root_when_base_has_become(self):
"""Venv chown wraps with USER root when inheriting non-root context."""
config = {
"names": {"image": "test", "workspace": "workspace", "user": "appuser"},
"stages": {
"base": {
"from": "debian:bookworm-slim",
"steps": [
{"pip": {"install": ["flask"]}},
{"create": "user"},
{"become": "user"},
],
},
"development": {"from": "base"},
"production": {"from": "base"},
},
}
content = _generate(config)
lines = content.splitlines()
chown_lines = [
i for i, line in enumerate(lines) if "chown" in line and "/opt/venv" in line
]
assert len(chown_lines) >= 1
# USER root should appear before the chown
chown_idx = chown_lines[0]
preceding = [line.strip() for line in lines[:chown_idx] if line.strip()]
assert preceding[-1] == "USER root"

def test_stage_from_external_image_resets_venv(self):
"""Stage from external image (not parent stage) starts fresh."""
config = {
Expand Down
Loading