Skip to content

Commit 70e99db

Browse files
committed
Added container-based CFEngine package builder
Introduced build-in-container, a Python/Docker-based build system that builds CFEngine packages inside containers using the existing build scripts. Ticket: ENT-13777 Signed-off-by: Lars Erik Wik <lars.erik.wik@northern.tech>
1 parent fe841b0 commit 70e99db

4 files changed

Lines changed: 627 additions & 0 deletions

File tree

build-in-container

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
#!/usr/bin/env python3
2+
"""Container-based CFEngine package builder.
3+
4+
Builds CFEngine packages inside Docker containers using the existing build
5+
scripts. Each build runs in a fresh ephemeral container.
6+
"""
7+
8+
import argparse
9+
import hashlib
10+
import json
11+
import logging
12+
import subprocess
13+
import sys
14+
from pathlib import Path
15+
16+
log = logging.getLogger("build-in-container")
17+
18+
PLATFORMS = {
19+
"ubuntu-20": {
20+
"base_image": "ubuntu:20.04",
21+
"dockerfile": "Dockerfile.debian",
22+
"extra_build_args": {"NCURSES_PKGS": "libncurses5 libncurses5-dev"},
23+
},
24+
"ubuntu-22": {
25+
"base_image": "ubuntu:22.04",
26+
"dockerfile": "Dockerfile.debian",
27+
"extra_build_args": {},
28+
},
29+
"ubuntu-24": {
30+
"base_image": "ubuntu:24.04",
31+
"dockerfile": "Dockerfile.debian",
32+
"extra_build_args": {},
33+
},
34+
"debian-11": {
35+
"base_image": "debian:11",
36+
"dockerfile": "Dockerfile.debian",
37+
"extra_build_args": {},
38+
},
39+
"debian-12": {
40+
"base_image": "debian:12",
41+
"dockerfile": "Dockerfile.debian",
42+
"extra_build_args": {},
43+
},
44+
}
45+
46+
CONFIG_DIR = Path.home() / ".config" / "build-in-container"
47+
CONFIG_FILE = CONFIG_DIR / "last-config.json"
48+
49+
HARDCODED_DEFAULTS = {
50+
"platform": "ubuntu-20",
51+
"project": "community",
52+
"role": "agent",
53+
"build_type": "DEBUG",
54+
}
55+
56+
57+
def load_last_config():
58+
"""Load last-used config, falling back to hardcoded defaults."""
59+
try:
60+
return json.loads(CONFIG_FILE.read_text())
61+
except (FileNotFoundError, json.JSONDecodeError):
62+
return dict(HARDCODED_DEFAULTS)
63+
64+
65+
def save_last_config(config):
66+
"""Persist the resolved config for next run."""
67+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
68+
CONFIG_FILE.write_text(json.dumps(config, indent=2) + "\n")
69+
70+
71+
def detect_source_dir():
72+
"""Find the root directory containing all repos (parent of buildscripts/)."""
73+
script_dir = Path(__file__).resolve().parent
74+
# The script lives in buildscripts/, so the source dir is one level up
75+
source_dir = script_dir.parent
76+
if not (source_dir / "buildscripts").is_dir():
77+
log.error(f"Cannot find buildscripts/ in {source_dir}")
78+
sys.exit(1)
79+
return source_dir
80+
81+
82+
def resolve_config(args):
83+
"""Fill in missing options from last-used config, then save."""
84+
last = load_last_config()
85+
86+
if args.platform is None:
87+
args.platform = last["platform"]
88+
if args.project is None:
89+
args.project = last["project"]
90+
if args.role is None:
91+
args.role = last["role"]
92+
if args.build_type is None:
93+
args.build_type = last["build_type"]
94+
95+
save_last_config(
96+
{
97+
"platform": args.platform,
98+
"project": args.project,
99+
"role": args.role,
100+
"build_type": args.build_type,
101+
}
102+
)
103+
104+
105+
def dockerfile_hash(dockerfile_path):
106+
"""Compute SHA256 hash of a Dockerfile."""
107+
return hashlib.sha256(dockerfile_path.read_bytes()).hexdigest()
108+
109+
110+
def image_needs_rebuild(image_tag, current_hash):
111+
"""Check if the Docker image needs rebuilding based on Dockerfile hash."""
112+
result = subprocess.run(
113+
[
114+
"docker",
115+
"inspect",
116+
"--format",
117+
'{{index .Config.Labels "dockerfile-hash"}}',
118+
image_tag,
119+
],
120+
capture_output=True,
121+
text=True,
122+
)
123+
if result.returncode != 0:
124+
return True # Image doesn't exist
125+
stored_hash = result.stdout.strip()
126+
return stored_hash != current_hash
127+
128+
129+
def build_image(platform_name, platform_config, script_dir, rebuild=False):
130+
"""Build the Docker image for the given platform."""
131+
image_tag = f"cfengine-builder-{platform_name}"
132+
dockerfile_name = platform_config["dockerfile"]
133+
dockerfile_path = script_dir / "container" / dockerfile_name
134+
current_hash = dockerfile_hash(dockerfile_path)
135+
136+
if not rebuild and not image_needs_rebuild(image_tag, current_hash):
137+
log.info(f"Docker image {image_tag} is up to date.")
138+
return image_tag
139+
140+
log.info(f"Building Docker image {image_tag}...")
141+
cmd = [
142+
"docker",
143+
"build",
144+
"-f",
145+
str(dockerfile_path),
146+
"--build-arg",
147+
f"BASE_IMAGE={platform_config['base_image']}",
148+
"--label",
149+
f"dockerfile-hash={current_hash}",
150+
"-t",
151+
image_tag,
152+
]
153+
154+
for key, value in platform_config.get("extra_build_args", {}).items():
155+
cmd.extend(["--build-arg", f"{key}={value}"])
156+
157+
if rebuild:
158+
cmd.append("--no-cache")
159+
160+
cmd.extend(["--network", "host"])
161+
162+
# Build context is the container/ directory
163+
cmd.append(str(script_dir / "container"))
164+
165+
result = subprocess.run(cmd)
166+
if result.returncode != 0:
167+
log.error("Docker image build failed.")
168+
sys.exit(1)
169+
170+
return image_tag
171+
172+
173+
def run_container(args, image_tag, source_dir, script_dir):
174+
"""Run the build inside a Docker container."""
175+
output_dir = Path(args.output_dir).resolve()
176+
cache_dir = Path(args.cache_dir).resolve()
177+
178+
# Pre-create host directories so Docker doesn't create them as root
179+
output_dir.mkdir(parents=True, exist_ok=True)
180+
cache_dir.mkdir(parents=True, exist_ok=True)
181+
182+
cmd = ["docker", "run", "--rm", "--network", "host"]
183+
184+
if args.shell:
185+
cmd.extend(["-it"])
186+
187+
# Mounts
188+
cmd.extend(
189+
[
190+
"-v",
191+
f"{source_dir}:/srv/source:ro",
192+
"-v",
193+
f"{cache_dir}:/home/builder/.cache/buildscripts_cache",
194+
"-v",
195+
f"{output_dir}:/output",
196+
]
197+
)
198+
199+
# Environment variables
200+
# JOB_BASE_NAME is used by deps-packaging/pkg-cache to derive the cache
201+
# label. Format: "label=<value>". Without it, all platforms share NO_LABEL.
202+
cache_label = f"label=container_{args.platform}"
203+
cmd.extend(
204+
[
205+
"-e",
206+
f"PROJECT={args.project}",
207+
"-e",
208+
f"BUILD_TYPE={args.build_type}",
209+
"-e",
210+
f"EXPLICIT_ROLE={args.role}",
211+
"-e",
212+
f"BUILD_NUMBER={args.build_number}",
213+
"-e",
214+
f"JOB_BASE_NAME={cache_label}",
215+
"-e",
216+
"CACHE_IS_ONLY_LOCAL=yes",
217+
]
218+
)
219+
220+
if args.version:
221+
cmd.extend(["-e", f"EXPLICIT_VERSION={args.version}"])
222+
223+
cmd.append(image_tag)
224+
225+
if args.shell:
226+
cmd.append("/bin/bash")
227+
else:
228+
cmd.append(str(Path("/srv/source/buildscripts/build-in-container-inner")))
229+
230+
result = subprocess.run(cmd)
231+
return result.returncode
232+
233+
234+
def main():
235+
parser = argparse.ArgumentParser(
236+
description="Build CFEngine packages in Docker containers."
237+
)
238+
parser.add_argument(
239+
"--platform",
240+
choices=list(PLATFORMS.keys()),
241+
help="Target platform",
242+
)
243+
parser.add_argument(
244+
"--project",
245+
choices=["community", "nova"],
246+
help="CFEngine edition (default: auto-detect from source dirs)",
247+
)
248+
parser.add_argument(
249+
"--role",
250+
choices=["agent", "hub"],
251+
help="Component to build (default: agent)",
252+
)
253+
parser.add_argument(
254+
"--build-type",
255+
dest="build_type",
256+
choices=["DEBUG", "RELEASE"],
257+
help="Build type (default: DEBUG)",
258+
)
259+
parser.add_argument(
260+
"--source-dir",
261+
help="Root directory containing repos (default: auto-detect)",
262+
)
263+
parser.add_argument(
264+
"--output-dir",
265+
default="./output",
266+
help="Output directory for packages (default: ./output)",
267+
)
268+
parser.add_argument(
269+
"--cache-dir",
270+
default=str(Path.home() / ".cache" / "buildscripts_cache"),
271+
help="Dependency cache directory",
272+
)
273+
parser.add_argument(
274+
"--rebuild-image",
275+
action="store_true",
276+
help="Force rebuild of Docker image (--no-cache)",
277+
)
278+
parser.add_argument(
279+
"--shell",
280+
action="store_true",
281+
help="Drop into container shell for debugging",
282+
)
283+
parser.add_argument(
284+
"--list-platforms",
285+
action="store_true",
286+
help="List available platforms and exit",
287+
)
288+
parser.add_argument(
289+
"--build-number",
290+
default="1",
291+
help="Build number for package versioning (default: 1)",
292+
)
293+
parser.add_argument(
294+
"--version",
295+
help="Override version string",
296+
)
297+
args = parser.parse_args()
298+
299+
logging.basicConfig(
300+
level=logging.INFO,
301+
format="%(message)s",
302+
)
303+
304+
# --list-platforms: print and exit
305+
if args.list_platforms:
306+
print("Available platforms:")
307+
for name, config in PLATFORMS.items():
308+
print(f" {name:15s} ({config['base_image']})")
309+
sys.exit(0)
310+
311+
# Detect source directory
312+
if args.source_dir:
313+
source_dir = Path(args.source_dir).resolve()
314+
else:
315+
source_dir = detect_source_dir()
316+
317+
script_dir = source_dir / "buildscripts"
318+
319+
# Fill in unspecified options from last-used config
320+
resolve_config(args)
321+
322+
# Validate platform
323+
if args.platform not in PLATFORMS:
324+
log.error(f"Unknown platform '{args.platform}'")
325+
sys.exit(1)
326+
327+
platform_config = PLATFORMS[args.platform]
328+
329+
# Build Docker image
330+
image_tag = build_image(
331+
args.platform, platform_config, script_dir, rebuild=args.rebuild_image
332+
)
333+
334+
if not args.shell:
335+
log.info(
336+
f"Building {args.project} {args.role} for {args.platform} ({args.build_type})..."
337+
)
338+
339+
# Run the container
340+
rc = run_container(args, image_tag, source_dir, script_dir)
341+
342+
if rc != 0:
343+
log.error(f"Build failed (exit code {rc}).")
344+
sys.exit(rc)
345+
346+
if not args.shell:
347+
output_dir = Path(args.output_dir).resolve()
348+
packages = (
349+
list(output_dir.glob("*.deb"))
350+
+ list(output_dir.glob("*.rpm"))
351+
+ list(output_dir.glob("*.pkg.tar.gz"))
352+
)
353+
if packages:
354+
log.info("Output packages:")
355+
for p in sorted(packages):
356+
log.info(f" {p}")
357+
else:
358+
log.warning("No packages found in output directory.")
359+
360+
361+
if __name__ == "__main__":
362+
main()

0 commit comments

Comments
 (0)