Skip to content

Commit 2e79666

Browse files
committed
feat: add build command for pre-built init packages
Add a new `build` command that creates Containerfiles with distrobox dependencies pre-installed. This allows `create` to use cached locally-built images, significantly speeding up container startup. New files: - src/distrobox_plus/utils/templates.py: Package lists and Containerfile templates - src/distrobox_plus/utils/builder.py: Build logic and image management - src/distrobox_plus/commands/build.py: CLI command implementation Changes: - cli.py: Add build command to router - create.py: Auto-build boosted images when additional packages/hooks specified - distrobox-init.sh: Skip package setup for boosted images (/.distrobox-boost marker) Tests: - 54 unit tests for templates and builder modules - 14 integration tests for build command
1 parent 09b7890 commit 2e79666

11 files changed

Lines changed: 1826 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ markers = [
4949
"slow: marks tests as slow (> 30 seconds, full container lifecycle)",
5050
"integration: marks tests as requiring real container operations",
5151
"assemble: tests for distrobox-assemble command",
52+
"build: tests for distrobox-build command",
5253
"create: tests for distrobox-create command",
5354
"enter: tests for distrobox-enter command",
5455
"ephemeral: tests for distrobox-ephemeral command",

src/distrobox_plus/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def _main(argv: list[str] | None = None) -> int:
4747
subparsers = parser.add_subparsers(dest="command", metavar="command")
4848

4949
subparsers.add_parser("assemble", help="Create containers from a manifest file")
50+
subparsers.add_parser("build", help="Build optimized container image")
5051
subparsers.add_parser("create", help="Create a new container")
5152
subparsers.add_parser("enter", help="Enter a container")
5253
subparsers.add_parser("ephemeral", help="Create a temporary container")
@@ -86,6 +87,11 @@ def _main(argv: list[str] | None = None) -> int:
8687

8788
return run(args)
8889

90+
case "build":
91+
from .commands.build import run
92+
93+
return run(args)
94+
8995
case "create":
9096
from .commands.create import run
9197

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""distrobox-build command implementation.
2+
3+
Builds optimized container images with distrobox dependencies pre-installed.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import argparse
9+
import sys
10+
11+
from ..config import VERSION, Config, check_sudo_doas
12+
from ..container import detect_container_manager
13+
from ..utils.builder import ensure_boost_image, get_boost_image_tag
14+
from ..utils.console import green, print_error, red
15+
16+
17+
def build_parser() -> argparse.ArgumentParser:
18+
"""Create argument parser for distrobox-build."""
19+
epilog = """\
20+
Examples:
21+
distrobox build --image alpine:latest
22+
distrobox build --image fedora:40 --additional-packages "git vim"
23+
distrobox build --image ubuntu:22.04 --init-hooks "touch /var/tmp/marker"
24+
distrobox build --image archlinux:latest --pre-init-hooks "pacman-key --init"
25+
26+
The build command creates a locally-cached container image with distrobox
27+
dependencies pre-installed. This significantly speeds up container creation
28+
and first-run startup time.
29+
30+
Built images are tagged with a hash of their configuration, enabling automatic
31+
caching - the same configuration will reuse an existing build.
32+
"""
33+
34+
parser = argparse.ArgumentParser(
35+
prog="distrobox-build",
36+
description="Build an optimized container image with distrobox dependencies pre-installed",
37+
epilog=epilog,
38+
formatter_class=argparse.RawDescriptionHelpFormatter,
39+
)
40+
parser.add_argument(
41+
"-i",
42+
"--image",
43+
required=True,
44+
help="base image to build from (required)",
45+
)
46+
parser.add_argument(
47+
"-ap",
48+
"--additional-packages",
49+
action="append",
50+
default=[],
51+
help="additional packages to install in the image",
52+
)
53+
parser.add_argument(
54+
"--init-hooks",
55+
help="commands to execute at the end of image setup",
56+
)
57+
parser.add_argument(
58+
"--pre-init-hooks",
59+
help="commands to execute at the start of image setup",
60+
)
61+
parser.add_argument(
62+
"-f",
63+
"--force",
64+
action="store_true",
65+
help="force rebuild even if image already exists",
66+
)
67+
parser.add_argument(
68+
"-d",
69+
"--dry-run",
70+
action="store_true",
71+
help="only print the Containerfile without building",
72+
)
73+
parser.add_argument(
74+
"-r",
75+
"--root",
76+
action="store_true",
77+
help="use root privileges for building",
78+
)
79+
parser.add_argument(
80+
"-v",
81+
"--verbose",
82+
action="store_true",
83+
help="show more verbosity",
84+
)
85+
parser.add_argument(
86+
"-V",
87+
"--version",
88+
action="version",
89+
version=f"distrobox: {VERSION}",
90+
)
91+
92+
return parser
93+
94+
95+
def _print_sudo_error() -> int:
96+
"""Print error message when running via sudo/doas."""
97+
print_error(f"Running {sys.argv[0]} via SUDO/DOAS is not supported.")
98+
print_error(f"Instead, please try running:\n {sys.argv[0]} --root")
99+
return 1
100+
101+
102+
def run(args: list[str] | None = None) -> int:
103+
"""Run the distrobox-build command.
104+
105+
Args:
106+
args: Command line arguments (uses sys.argv if None)
107+
108+
Returns:
109+
Exit code
110+
"""
111+
if check_sudo_doas():
112+
return _print_sudo_error()
113+
114+
parser = build_parser()
115+
parsed = parser.parse_args(args)
116+
117+
config = Config.load()
118+
119+
# Apply CLI overrides
120+
if parsed.root:
121+
config.rootful = True
122+
if parsed.verbose:
123+
config.verbose = True
124+
125+
# Get container manager
126+
manager = detect_container_manager(
127+
preferred=config.container_manager,
128+
verbose=config.verbose,
129+
rootful=config.rootful,
130+
sudo_program=config.sudo_program,
131+
)
132+
133+
# Prepare options
134+
image = parsed.image
135+
additional_packages = " ".join(parsed.additional_packages)
136+
init_hooks = parsed.init_hooks or ""
137+
pre_init_hooks = parsed.pre_init_hooks or ""
138+
139+
# Dry run - show Containerfile
140+
if parsed.dry_run:
141+
from ..utils.builder import generate_containerfile
142+
143+
containerfile = generate_containerfile(
144+
image,
145+
additional_packages,
146+
init_hooks,
147+
pre_init_hooks,
148+
)
149+
print("# Generated Containerfile:")
150+
print(containerfile)
151+
tag = get_boost_image_tag(image, additional_packages, init_hooks, pre_init_hooks)
152+
print(f"# Would be tagged as: {tag}")
153+
return 0
154+
155+
# Check if base image exists, pull if not
156+
if not manager.image_exists(image):
157+
print_error(f"Pulling base image: {image}")
158+
if not manager.pull(image):
159+
print_error(red(f"Failed to pull image: {image}"))
160+
return 1
161+
162+
# Build the image
163+
result = ensure_boost_image(
164+
manager,
165+
image,
166+
additional_packages,
167+
init_hooks,
168+
pre_init_hooks,
169+
verbose=config.verbose,
170+
force=parsed.force,
171+
)
172+
173+
if result:
174+
print_error(green(f"Built image: {result}"))
175+
print_error("\nTo use this image, run:")
176+
print_error(f" distrobox create --image {result} --name <container-name>")
177+
return 0
178+
else:
179+
print_error(red("Build failed"))
180+
return 1
181+
182+
183+
if __name__ == "__main__":
184+
sys.exit(run())

src/distrobox_plus/commands/create.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
get_user_info,
2727
)
2828
from ..container import USERNS_SIZE, detect_container_manager
29+
from ..utils.builder import ensure_boost_image
2930
from ..utils.console import green, print_error, red
3031
from ..utils.helpers import (
3132
InvalidInputError,
@@ -965,6 +966,61 @@ def _execute_create(
965966
return result.returncode
966967

967968

969+
def _maybe_boost_image(
970+
manager: ContainerManager,
971+
opts: CreateOptions,
972+
config: Config,
973+
) -> bool:
974+
"""Build a boosted image if conditions are met.
975+
976+
Modifies opts.image to point to the boosted image and clears the
977+
additional_packages, init_hooks, and pre_init_hooks since they're
978+
baked into the image.
979+
980+
Args:
981+
manager: Container manager
982+
opts: Create options (will be modified)
983+
config: Configuration
984+
985+
Returns:
986+
True if successful (or boost not needed), False on failure
987+
"""
988+
# Skip boost if no packages or hooks to bake in
989+
if not opts.additional_packages and not opts.init_hooks and not opts.pre_init_hooks:
990+
return True
991+
992+
# Skip boost for cloned images (already customized)
993+
if opts.clone:
994+
return True
995+
996+
# Build the boosted image
997+
boosted = ensure_boost_image(
998+
manager,
999+
opts.image,
1000+
opts.additional_packages,
1001+
opts.init_hooks,
1002+
opts.pre_init_hooks,
1003+
verbose=config.verbose,
1004+
)
1005+
1006+
if boosted is None:
1007+
print_error(
1008+
"[warning]Warning: Failed to build boosted image, "
1009+
"falling back to standard creation[/warning]"
1010+
)
1011+
return True # Don't fail, just proceed without boost
1012+
1013+
# Update opts to use the boosted image
1014+
opts.image = boosted
1015+
1016+
# Clear the baked-in options so they're not passed to entrypoint again
1017+
opts.additional_packages = ""
1018+
opts.init_hooks = ""
1019+
opts.pre_init_hooks = ""
1020+
1021+
return True
1022+
1023+
9681024
def run(args: list[str] | None = None) -> int:
9691025
"""Run the distrobox-create command.
9701026
@@ -1024,6 +1080,10 @@ def run(args: list[str] | None = None) -> int:
10241080
return 0
10251081
return 1
10261082

1083+
# Build boosted image if additional packages/hooks are specified
1084+
if not _maybe_boost_image(manager, opts, config):
1085+
return 1
1086+
10271087
return _execute_create(manager, opts, config)
10281088

10291089

0 commit comments

Comments
 (0)