Skip to content

Commit 6103cd0

Browse files
committed
feat: implement multi-stage build for better layer caching
Refactor generate_containerfile() to use multi-stage Docker/Podman builds with conditional stages that only exist when needed: - Stage 1 (init): Always present - boost marker, upgrade, install deps - Stage 2 (pre-hooks): Only if pre_init_hooks provided - Stage 3 (packages): Only if additional_packages provided - Stage 4 (runner): Only if init_hooks provided Each stage inherits from the previous existing stage, enabling efficient layer caching - only changed stages need to rebuild.
1 parent 2684b46 commit 6103cd0

2 files changed

Lines changed: 188 additions & 53 deletions

File tree

src/distrobox_plus/utils/builder.py

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,16 @@ def generate_containerfile(
6969
init_hooks: str = "",
7070
pre_init_hooks: str = "",
7171
) -> str:
72-
"""Generate Containerfile content for a boosted image.
72+
"""Generate multi-stage Containerfile content for a boosted image.
73+
74+
Creates a multi-stage build with conditional stages:
75+
- Stage 1: init - Always present, installs base dependencies
76+
- Stage 2: pre-hooks - Only if pre_init_hooks provided
77+
- Stage 3: packages - Only if additional_packages provided
78+
- Stage 4: runner - Only if init_hooks provided
79+
80+
Each stage inherits from the previous existing stage, enabling
81+
efficient layer caching by Docker/Podman.
7382
7483
Args:
7584
base_image: Base image to build from
@@ -80,63 +89,60 @@ def generate_containerfile(
8089
Returns:
8190
Containerfile content as string
8291
"""
83-
lines = [
84-
f"FROM {base_image}",
85-
"",
86-
"# Marker for boosted image - distrobox-init will skip package setup",
87-
"RUN touch /.distrobox-boost",
88-
"",
89-
]
90-
91-
# Add upgrade command
92-
upgrade_cmd = generate_upgrade_cmd()
92+
lines: list[str] = []
93+
current_stage = "init"
94+
95+
# Stage 1: init (always present)
9396
lines.extend(
9497
[
98+
f"FROM {base_image} AS init",
99+
"",
100+
"# Marker for boosted image - distrobox-init will skip package setup",
101+
"RUN touch /.distrobox-boost",
102+
"",
95103
"# Upgrade existing packages",
96-
f"RUN {upgrade_cmd}",
104+
f"RUN {generate_upgrade_cmd()}",
105+
"",
106+
"# Install distrobox dependencies (conditional per-package check)",
107+
f"RUN {generate_install_cmd()}",
97108
"",
98109
]
99110
)
100111

101-
# Add pre-init hooks if specified
112+
# Stage 2: pre-hooks (conditional)
102113
if pre_init_hooks:
103-
hooks_cmd = generate_hooks_cmd(pre_init_hooks)
104114
lines.extend(
105115
[
116+
f"FROM {current_stage} AS pre-hooks",
117+
"",
106118
"# Pre-init hooks",
107-
f"RUN {hooks_cmd}",
119+
f"RUN {generate_hooks_cmd(pre_init_hooks)}",
108120
"",
109121
]
110122
)
123+
current_stage = "pre-hooks"
111124

112-
# Add distrobox package installation
113-
install_cmd = generate_install_cmd()
114-
lines.extend(
115-
[
116-
"# Install distrobox dependencies (conditional per-package check)",
117-
f"RUN {install_cmd}",
118-
"",
119-
]
120-
)
121-
122-
# Add additional packages if specified
125+
# Stage 3: packages (conditional)
123126
if additional_packages:
124-
additional_cmd = generate_additional_packages_cmd(additional_packages)
125127
lines.extend(
126128
[
129+
f"FROM {current_stage} AS packages",
130+
"",
127131
"# Install additional packages",
128-
f"RUN {additional_cmd}",
132+
f"RUN {generate_additional_packages_cmd(additional_packages)}",
129133
"",
130134
]
131135
)
136+
current_stage = "packages"
132137

133-
# Add init hooks if specified
138+
# Stage 4: runner (conditional)
134139
if init_hooks:
135-
hooks_cmd = generate_hooks_cmd(init_hooks)
136140
lines.extend(
137141
[
142+
f"FROM {current_stage} AS runner",
143+
"",
138144
"# Init hooks",
139-
f"RUN {hooks_cmd}",
145+
f"RUN {generate_hooks_cmd(init_hooks)}",
140146
"",
141147
]
142148
)

tests/unit/test_builder.py

Lines changed: 151 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ def test_hash_length(self):
8181
class TestGenerateContainerfile:
8282
"""Tests for generate_containerfile function."""
8383

84-
def test_starts_with_from(self):
85-
"""Test that Containerfile starts with FROM."""
84+
def test_starts_with_from_and_init_stage(self):
85+
"""Test that Containerfile starts with FROM and init stage."""
8686
result = generate_containerfile("alpine:latest")
87-
assert result.startswith("FROM alpine:latest")
87+
assert result.startswith("FROM alpine:latest AS init")
8888

8989
def test_contains_boost_marker(self):
9090
"""Test that Containerfile creates boost marker."""
@@ -136,7 +136,7 @@ def test_has_pre_init_hooks_with_arg(self):
136136
assert "echo hello" in result
137137

138138
def test_order_of_sections(self):
139-
"""Test that sections appear in correct order."""
139+
"""Test that sections appear in correct order with multi-stage build."""
140140
result = generate_containerfile(
141141
"alpine:latest",
142142
additional_packages="git",
@@ -146,44 +146,173 @@ def test_order_of_sections(self):
146146
lines = result.split("\n")
147147

148148
# Find positions
149+
init_stage_pos = None
149150
boost_pos = None
150151
upgrade_pos = None
151-
pre_hook_pos = None
152152
install_pos = None
153-
additional_pos = None
154-
init_hook_pos = None
153+
pre_hooks_stage_pos = None
154+
packages_stage_pos = None
155+
runner_stage_pos = None
155156

156157
for i, line in enumerate(lines):
158+
if "FROM alpine:latest AS init" in line:
159+
init_stage_pos = i
157160
if "/.distrobox-boost" in line:
158161
boost_pos = i
159162
if "# Upgrade" in line:
160163
upgrade_pos = i
161-
if "# Pre-init hooks" in line:
162-
pre_hook_pos = i
163164
if "# Install distrobox dependencies" in line:
164165
install_pos = i
165-
if "# Install additional packages" in line:
166-
additional_pos = i
167-
if "# Init hooks" in line:
168-
init_hook_pos = i
169-
170-
# Verify order
166+
if "FROM init AS pre-hooks" in line:
167+
pre_hooks_stage_pos = i
168+
if "FROM pre-hooks AS packages" in line:
169+
packages_stage_pos = i
170+
if "FROM packages AS runner" in line:
171+
runner_stage_pos = i
172+
173+
# Verify all stages present
174+
assert init_stage_pos is not None
171175
assert boost_pos is not None
172176
assert upgrade_pos is not None
173-
assert pre_hook_pos is not None
174177
assert install_pos is not None
175-
assert additional_pos is not None
176-
assert init_hook_pos is not None
178+
assert pre_hooks_stage_pos is not None
179+
assert packages_stage_pos is not None
180+
assert runner_stage_pos is not None
177181

182+
# Verify order within init stage
183+
assert init_stage_pos < boost_pos
178184
assert boost_pos < upgrade_pos
179-
assert upgrade_pos < pre_hook_pos
180-
assert pre_hook_pos < install_pos
181-
assert install_pos < additional_pos
182-
assert additional_pos < init_hook_pos
185+
assert upgrade_pos < install_pos
186+
187+
# Verify stage order
188+
assert install_pos < pre_hooks_stage_pos
189+
assert pre_hooks_stage_pos < packages_stage_pos
190+
assert packages_stage_pos < runner_stage_pos
183191

184192
def test_returns_multiline_string(self):
185193
"""Test that result is a valid multiline Containerfile."""
186194
result = generate_containerfile("alpine:latest")
187195
lines = result.split("\n")
188196
assert len(lines) > 5 # Should have multiple lines
189197
assert lines[0].startswith("FROM")
198+
199+
200+
class TestMultiStageBuild:
201+
"""Tests for multi-stage build structure."""
202+
203+
def test_multi_stage_all_options(self):
204+
"""Test that all 4 stages are present when all options provided."""
205+
result = generate_containerfile(
206+
"alpine:latest",
207+
additional_packages="git vim",
208+
init_hooks="echo init",
209+
pre_init_hooks="echo pre",
210+
)
211+
212+
assert "FROM alpine:latest AS init" in result
213+
assert "FROM init AS pre-hooks" in result
214+
assert "FROM pre-hooks AS packages" in result
215+
assert "FROM packages AS runner" in result
216+
217+
def test_multi_stage_skip_pre_hooks(self):
218+
"""Test that pre-hooks stage is skipped when no pre_init_hooks."""
219+
result = generate_containerfile(
220+
"alpine:latest",
221+
additional_packages="git",
222+
init_hooks="echo init",
223+
)
224+
225+
assert "FROM alpine:latest AS init" in result
226+
assert "AS pre-hooks" not in result
227+
# packages should inherit from init directly
228+
assert "FROM init AS packages" in result
229+
assert "FROM packages AS runner" in result
230+
231+
def test_multi_stage_skip_packages(self):
232+
"""Test that packages stage is skipped when no additional_packages."""
233+
result = generate_containerfile(
234+
"alpine:latest",
235+
init_hooks="echo init",
236+
pre_init_hooks="echo pre",
237+
)
238+
239+
assert "FROM alpine:latest AS init" in result
240+
assert "FROM init AS pre-hooks" in result
241+
assert "AS packages" not in result
242+
# runner should inherit from pre-hooks directly
243+
assert "FROM pre-hooks AS runner" in result
244+
245+
def test_multi_stage_only_packages(self):
246+
"""Test with only additional_packages."""
247+
result = generate_containerfile(
248+
"alpine:latest",
249+
additional_packages="git vim",
250+
)
251+
252+
assert "FROM alpine:latest AS init" in result
253+
assert "AS pre-hooks" not in result
254+
assert "FROM init AS packages" in result
255+
assert "AS runner" not in result
256+
257+
def test_multi_stage_base_only(self):
258+
"""Test that only init stage exists when no optional args."""
259+
result = generate_containerfile("alpine:latest")
260+
261+
assert "FROM alpine:latest AS init" in result
262+
assert "AS pre-hooks" not in result
263+
assert "AS packages" not in result
264+
assert "AS runner" not in result
265+
# Verify base content still present
266+
assert "touch /.distrobox-boost" in result
267+
assert "# Upgrade existing packages" in result
268+
assert "# Install distrobox dependencies" in result
269+
270+
def test_stage_chain_init_to_runner(self):
271+
"""Test correct FROM chain: init -> runner (no middle stages)."""
272+
result = generate_containerfile(
273+
"alpine:latest",
274+
init_hooks="echo init",
275+
)
276+
277+
assert "FROM alpine:latest AS init" in result
278+
assert "FROM init AS runner" in result
279+
assert "AS pre-hooks" not in result
280+
assert "AS packages" not in result
281+
282+
def test_stage_chain_with_pre_hooks_only(self):
283+
"""Test correct FROM chain: init -> pre-hooks (no packages/runner)."""
284+
result = generate_containerfile(
285+
"alpine:latest",
286+
pre_init_hooks="echo pre",
287+
)
288+
289+
assert "FROM alpine:latest AS init" in result
290+
assert "FROM init AS pre-hooks" in result
291+
assert "AS packages" not in result
292+
assert "AS runner" not in result
293+
294+
def test_stage_chain_packages_and_runner(self):
295+
"""Test correct FROM chain when skipping pre-hooks."""
296+
result = generate_containerfile(
297+
"alpine:latest",
298+
additional_packages="git",
299+
init_hooks="echo init",
300+
)
301+
302+
# packages should come from init (not pre-hooks)
303+
assert "FROM init AS packages" in result
304+
# runner should come from packages
305+
assert "FROM packages AS runner" in result
306+
307+
def test_stage_chain_pre_hooks_and_runner(self):
308+
"""Test correct FROM chain when skipping packages."""
309+
result = generate_containerfile(
310+
"alpine:latest",
311+
init_hooks="echo init",
312+
pre_init_hooks="echo pre",
313+
)
314+
315+
# pre-hooks from init
316+
assert "FROM init AS pre-hooks" in result
317+
# runner should come from pre-hooks (not packages)
318+
assert "FROM pre-hooks AS runner" in result

0 commit comments

Comments
 (0)