Skip to content

Commit 59f0c6b

Browse files
committed
Added CLI to automate building of Docker images
1 parent 400765c commit 59f0c6b

File tree

2 files changed

+305
-0
lines changed

2 files changed

+305
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ GitHub = "https://github.com/DiamondLightSource/python-murfey"
8383
[project.scripts]
8484
murfey = "murfey.client:run"
8585
"murfey.add_user" = "murfey.cli.add_user:run"
86+
"murfey.build_images" = "murfey.cli.build_images:run"
8687
"murfey.create_db" = "murfey.cli.create_db:run"
8788
"murfey.db_sql" = "murfey.cli.murfey_db_sql:run"
8889
"murfey.decrypt_password" = "murfey.cli.decrypt_db_password:run"

src/murfey/cli/build_images.py

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
"""
2+
Helper function to automate the process of building and publishing Docker images for
3+
Murfey using Python subprocesses.
4+
5+
This CLI is designed to run with Podman commands and in a bash shell that has been
6+
configured to push to a valid Docker repo, which has to be specified using a flag.
7+
"""
8+
9+
import grp
10+
import os
11+
import subprocess
12+
from argparse import ArgumentParser
13+
from pathlib import Path
14+
15+
16+
def run_subprocess(cmd: list[str], src: str = "."):
17+
process = subprocess.Popen(
18+
cmd,
19+
stdout=subprocess.PIPE,
20+
stderr=subprocess.PIPE,
21+
text=True,
22+
bufsize=1,
23+
universal_newlines=True,
24+
env=os.environ,
25+
cwd=Path(src),
26+
)
27+
28+
# Parse stdout and stderr
29+
if process.stdout:
30+
for line in process.stdout:
31+
print(line, end="")
32+
if process.stderr:
33+
for line in process.stderr:
34+
print(line, end="")
35+
36+
# Wait for process to complete
37+
process.wait()
38+
39+
return process.returncode
40+
41+
42+
# Function to build Docker image
43+
def build_image(
44+
image: str,
45+
tag: str,
46+
source: str,
47+
destination: str,
48+
user_id: int,
49+
group_id: int,
50+
group_name: str,
51+
dry_run: bool = False,
52+
):
53+
# Construct path to Dockerfile
54+
dockerfile = Path(source) / "Dockerfiles" / image
55+
if not dockerfile.exists():
56+
raise FileNotFoundError(
57+
f"Unable to find Dockerfile for {image} at {str(dockerfile)!r}"
58+
)
59+
60+
# Construct tag
61+
image_path = f"{destination}/{image}"
62+
if tag:
63+
image_path = f"{image_path}:{tag}"
64+
65+
# Construct bash command to build image
66+
build_cmd = [
67+
"podman build",
68+
f"--build-arg=userid={user_id}",
69+
f"--build-arg=groupid={group_id}",
70+
f"--build-arg=groupname={group_name}",
71+
"--no-cache",
72+
f"-f {str(dockerfile)}",
73+
f"-t {image_path}",
74+
f"{source}",
75+
]
76+
bash_cmd = ["bash", "-c", " ".join(build_cmd)]
77+
78+
if not dry_run:
79+
print()
80+
# Run subprocess command to build image
81+
result = run_subprocess(bash_cmd, source)
82+
83+
# Check for errors
84+
if result != 0:
85+
raise RuntimeError(f"Build command failed with exit code {result}")
86+
87+
if dry_run:
88+
print()
89+
print(f"Will build image {image!r}")
90+
print(f"Will use Dockerfile from {str(dockerfile)!r}")
91+
print(
92+
f"Will build image with UID {user_id}, GID {group_id}, and group name {group_name}"
93+
)
94+
print(f"Will build image with tag {image_path}")
95+
print("Will run the following bash command:")
96+
print(bash_cmd)
97+
98+
return image_path
99+
100+
101+
def tag_image(
102+
image_path: str,
103+
tags: list[str],
104+
dry_run: bool = False,
105+
):
106+
# Construct list of tags to create
107+
base_path = image_path.split(":")[0]
108+
new_tags = [f"{base_path}:{tag}" for tag in tags]
109+
110+
# Construct bash command to add all additional tags
111+
tag_cmd = [
112+
f"for IMAGE in {' '.join(new_tags)};",
113+
f"do podman tag {image_path} $IMAGE;",
114+
"done",
115+
]
116+
bash_cmd = ["bash", "-c", " ".join(tag_cmd)]
117+
if not dry_run:
118+
print()
119+
# Run subprocess command to tag image
120+
result = run_subprocess(bash_cmd)
121+
122+
# Check for errors
123+
if result != 0:
124+
raise RuntimeError(f"Tag command failed with exit code {result}")
125+
126+
if dry_run:
127+
print()
128+
print("Will run the following bash command:")
129+
print(bash_cmd)
130+
for tag in new_tags:
131+
print(f"Will create new tag {tag}")
132+
133+
return new_tags
134+
135+
136+
def push_images(
137+
images: list[str],
138+
dry_run: bool = False,
139+
):
140+
# Construct bash command to push images
141+
push_cmd = [f"for IMAGE in {' '.join(images)};", "do podman push $IMAGE;", "done"]
142+
bash_cmd = ["bash", "-c", " ".join(push_cmd)]
143+
if not dry_run:
144+
print()
145+
# Run subprocess command to push image
146+
result = run_subprocess(bash_cmd)
147+
148+
# Check for errors
149+
if result != 0:
150+
raise RuntimeError(f"Push command failed with exit code {result}")
151+
152+
if dry_run:
153+
print()
154+
print("Will run the following bash command:")
155+
print(bash_cmd)
156+
for image in images:
157+
print(f"Will push image {image}")
158+
159+
return True
160+
161+
162+
def cleanup(dry_run: bool = False):
163+
# Construct bash command to push images
164+
cleanup_cmd = [
165+
"podman image prune -f",
166+
]
167+
bash_cmd = ["bash", "-c", " ".join(cleanup_cmd)]
168+
if not dry_run:
169+
print()
170+
# Run subprocess command to clean up Podman repo
171+
result = run_subprocess(bash_cmd)
172+
173+
# Check for errors
174+
if result != 0:
175+
raise RuntimeError(f"Cleanup command failed with exit code {result}")
176+
177+
if dry_run:
178+
print()
179+
print("Will run the following bash command:")
180+
print(bash_cmd)
181+
182+
return True
183+
184+
185+
def run():
186+
187+
parser = ArgumentParser(
188+
description=(
189+
"Uses Podman to build, tag, and push the specified images either locally "
190+
"or to a remote repository"
191+
)
192+
)
193+
194+
parser.add_argument(
195+
"images",
196+
nargs="+",
197+
help=("Space-separated list of Murfey Dockerfiles that you want to build."),
198+
)
199+
200+
parser.add_argument(
201+
"--tags",
202+
"-t",
203+
nargs="*",
204+
default=["latest"],
205+
help=("Space-separated list of tags to apply to the built images"),
206+
)
207+
208+
parser.add_argument(
209+
"--source",
210+
"-s",
211+
default=".",
212+
help=("Directory path to the Murfey repository"),
213+
)
214+
215+
parser.add_argument(
216+
"--destination",
217+
"-d",
218+
default="localhost",
219+
help=("The URL of the repo to push the built images to"),
220+
)
221+
222+
parser.add_argument(
223+
"--user-id",
224+
default=os.getuid(),
225+
help=("The user ID to install in the images"),
226+
)
227+
228+
parser.add_argument(
229+
"--group-id",
230+
default=os.getgid(),
231+
help=("The group ID to install in the images"),
232+
)
233+
234+
parser.add_argument(
235+
"--group-name",
236+
default=(
237+
grp.getgrgid(os.getgid()).gr_name if hasattr(grp, "getgrgid") else "nogroup"
238+
),
239+
help=("The group name to install in the images"),
240+
)
241+
242+
parser.add_argument(
243+
"--dry-run",
244+
default=False,
245+
action="store_true",
246+
help=(
247+
"When specified, prints out what the command would have done for each "
248+
"stage of the process"
249+
),
250+
)
251+
252+
args = parser.parse_args()
253+
254+
# Validate the paths to the images
255+
for image in args.images:
256+
if not (Path(args.source) / "Dockerfiles" / str(image)).exists():
257+
raise FileNotFoundError(
258+
"No Dockerfile found in "
259+
f"source repository {str(Path(args.source).resolve())!r} for"
260+
f"image {str(image)!r}"
261+
)
262+
263+
# Build image
264+
images = []
265+
for image in args.images:
266+
image_path = build_image(
267+
image=image,
268+
tag=args.tags[0],
269+
source=args.source,
270+
destination=(
271+
str(args.destination).rstrip("/")
272+
if str(args.destination).endswith("/")
273+
else str(args.destination)
274+
),
275+
user_id=args.user_id,
276+
group_id=args.group_id,
277+
group_name=args.group_name,
278+
dry_run=args.dry_run,
279+
)
280+
images.append(image_path)
281+
282+
# Create additional tags (if any) for each built image
283+
if len(args.tags) > 1:
284+
new_tags = tag_image(
285+
image_path=image_path,
286+
tags=args.tags[1:],
287+
dry_run=args.dry_run,
288+
)
289+
images.extend(new_tags)
290+
291+
# Push all built images to specified repo
292+
push_images(images, dry_run=args.dry_run)
293+
294+
# Perform final cleanup
295+
cleanup(dry_run=args.dry_run)
296+
297+
# Notify that job is completed
298+
print()
299+
print("Done")
300+
301+
302+
# Allow it to be run directly from the file
303+
if __name__ == "__main__":
304+
run()

0 commit comments

Comments
 (0)